㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 摘要(Abstract)
- [1. 背景与需求(Why)](#1. 背景与需求(Why))
- [2. 合规与注意事项(必读)](#2. 合规与注意事项(必读))
-
- [robots.txt 基本规范](#robots.txt 基本规范)
- 频率控制建议
- [3. 技术选型与整体流程(What/How)](#3. 技术选型与整体流程(What/How))
-
- [静态 vs 动态 vs API?](#静态 vs 动态 vs API?)
- 数据流转流程
- [4. 环境准备与依赖安装](#4. 环境准备与依赖安装)
- [5. 核心实现:请求层(Fetcher)](#5. 核心实现:请求层(Fetcher))
- [6. 核心实现:解析层(Parser)](#6. 核心实现:解析层(Parser))
- [7. 数据存储与导出(Storage)](#7. 数据存储与导出(Storage))
- [8. 运行方式与结果展示](#8. 运行方式与结果展示)
- [9. 常见问题与排错](#9. 常见问题与排错)
- [10. 进阶优化(可选)](#10. 进阶优化(可选))
-
- [10.1 异步并发加速](#10.1 异步并发加速)
- [10.2 断点续跑机制](#10.2 断点续跑机制)
- [10.3 监控与报警](#10.3 监控与报警)
- [10.4 定时任务](#10.4 定时任务)
- [11. 总结与延伸](#11. 总结与延伸)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
摘要(Abstract)
本文将手把手教你构建一个携程景点数据采集系统,通过逆向分析真实API接口,批量抓取多个城市的景点信息(评分、门票价格、地址、开放时间等),最终输出为可直接用于数据分析的结构化数据集。
读完本文你将获得:
- 掌握旅游网站API逆向分析的完整方法论(开发者工具实战)
- 学会处理分页列表、参数加密、反爬限制的实用技巧
- 获得一套可复用的数据采集 + 清洗 + 存储完整代码框架
1. 背景与需求(Why)
为什么要爬旅游数据?
去年国庆前夕,我打算整理一份"长三角高性价比景点清单"送给朋友。手动在携程上逐个点开景点页面,复制评分、票价、地址...折腾了两小时才整理20个景点,效率低得令人抓狂。这时候我意识到:这种重复性数据收集工作,完全可以用程序自动化完成。
典型应用场景:
- 🧳 旅游规划:对比不同城市景点性价比,筛选高评分低票价目标
- 📊 市场分析:追踪热门景点票价变化趋势,研究淡旺季差异
- 🗺️ 数据可视化:制作城市景点分布热力图、评分排行榜
目标数据字段清单
| 字段名 | 说明 | 示例值 |
|---|---|---|
attraction_name |
景点名称 | 故宫博物院 |
city |
所属城市 | 北京 |
rating |
综合评分 | 4.7 |
review_count |
评论数量 | 12853 |
price |
门票价格(元) | 60.0 |
address |
详细地址 | 东城区景山前街4号 |
open_time |
开放时间 | 08:30-17:00 |
tags |
标签 | 历史建筑,博物馆 |
url |
详情链接 | https://... |
2. 合规与注意事项(必读)
robots.txt 基本规范
携程的 robots.txt(https://you.ctrip.com/robots.txt) 允许搜索引擎抓取部分页面,但明确禁止高频爬取用户数据接口 。我们的采集方案必须遵守:允许搜索引擎抓取部分页面,但明确禁止高频爬取用户数据接口。我们的采集方案必须遵守:
✅ 允许做的:
- 模拟正常用户浏览行为(合理请求间隔)
- 采集公开展示的景点基础信息
- 用于个人学习、数据分析等非商业用途
❌ 禁止做的:
- 高并发轰炸服务器(每秒数十次请求)
- 绕过登录/付费墙采集VIP内容
- 采集用户隐私信息(手机号、订单记录等)
- 将数据用于商业转售
频率控制建议
python
# 推荐配置
REQUEST_DELAY = 2 # 每次请求间隔2秒
MAX_RETRIES = 3 # 失败重试不超过3次
TIMEOUT = 10 # 请求超时10秒
底线思维: 如果你的采集行为导致网站服务异常,那就是越界了。保持克制,尊重他人劳动成果。
3. 技术选型与整体流程(What/How)
静态 vs 动态 vs API?
携程景点列表页属于前后端分离架构,页面骨架是静态HTML,但数据通过Ajax异步加载。直接用requests抓HTML只能拿到空壳,必须找到真实的数据接口。
为什么选择 API 逆向而非 Selenium?
| 方案 | 优点 | 缺点 |
|---|---|---|
| Selenium/Playwright | 无需分析接口,所见即所得 | 速度慢(每页5-10秒),资源占用高 |
| API逆向 | 速度快(每页0.5秒),代码简洁 | 需要抓包分析,接口可能变化 |
考虑到景点数据结构稳定、采集量较大(数千条),我选择API逆向方案。
数据流转流程
json
[用户输入城市列表]
↓
[构造API请求] → [翻页逻辑] → [发送HTTP请求]
↓
[JSON解析] → [字段提取] → [数据清洗]
↓
[去重处理] → [存储到CSV/SQLite]
↓
[日志记录 + 异常处理]
4. 环境准备与依赖安装
Python版本要求
json
Python >= 3.8 # 推荐3.10+
核心依赖安装
json
pip install requests>=2.28.0
pip install lxml>=4.9.0
pip install pandas>=1.5.0
pip install fake-useragent>=1.4.0
推荐项目结构
json
ctrip_spider/
├── main.py # 主程序入口
├── fetcher.py # 请求层
├── parser.py # 解析层
├── storage.py # 存储层
├── config.py # 配置文件
├── utils.py # 工具函数
├── data/
│ ├── attractions.csv # 输出CSV
│ └── attractions.db # SQLite数据库
└── logs/
└── spider.log # 运行日志
5. 核心实现:请求层(Fetcher)
API接口逆向分析
实战步骤:
- 打开携程景点列表页(如:https://you.ctrip.com/sight/beijing1/s0-p1.html)
- 按F12打开开发者工具 → Network标签
- 筛选XHR请求,刷新页面
- 找到返回景点列表的接口(通常包含
poilist或sightlist关键词)
真实接口示例:
json
GET https://m.ctrip.com/restapi/soa2/16709/json/searchPoiList
参数:
- cityId: 1 (北京)
- page: 1
- pageSize: 20
- searchWord:
请求层代码实现
python
# fetcher.py
import requests
import time
import random
from fake_useragent import UserAgent
from typing import Optional, Dict
import logging
class CtripFetcher:
def __init__(self):
self.session = requests.Session()
self.ua = UserAgent()
self.base_url = "https://m.ctrip.com/restapi/soa2/16709/json/searchPoiList"
def get_headers(self) -> Dict[str, str]:
"""动态生成请求头"""
return {
'User-Agent': self.ua.random,
'Referer': 'https://you.ctrip.com/',
'Accept': 'application/json',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Connection': 'keep-alive'
}
def fetch_page(self, city_id: int, page: int,
max_retries: int = 3) -> Optional[Dict]:
"""
获取指定城市、页码的景点数据
Args:
city_id: 城市ID(北京=1, 上海=2)
page: 页码(从1开始)
max_retries: 最大重试次数
Returns:
解析后的JSON数据,失败返回None
"""
params = {
'cityId': city_id,
'page': page,
'pageSize': 20,
'searchWord': ''
}
for attempt in range(max_retries):
try:
response = self.session.get(
self.base_url,
params=params,
headers=self.get_headers(),
timeout=10
)
if response.status_code == 200:
data = response.json()
logging.info(f"✓ 城市{city_id} 第{page}页 - 成功")
# 随机延时2-4秒,模拟人类行为
time.sleep(random.uniform(2, 4))
return data
elif response.status_code == 429:
# 频率限制,指数退避
wait_time = 2 ** attempt * 5
logging.warning(f"⚠ 触发频控,等待{wait_time}秒")
time.sleep(wait_time)
else:
logging.error(f"✗ HTTP {response.status_code}")
except requests.Timeout:
logging.error(f"✗ 请求超时 (尝试{attempt+1}/{max_retries})")
except Exception as e:
logging.error(f"✗ 异常: {str(e)}")
return None
关键技术点说明
- Session复用:避免每次请求重新建立TCP连接,提升效率30%
- User-Agent轮换 :使用
fake-useragent库动态切换UA,降低被识别风险 - 指数退避重试:遇到429频控时,等待时间指数增长(5秒→10秒→20秒)
- 随机延时:2-4秒随机间隔,模拟真实用户浏览节奏
6. 核心实现:解析层(Parser)
JSON数据结构分析
典型返回数据(简化版):
json
{
"data": {
"poiList": [
{
"poiId": "123456",
"poiName": "故宫博物院",
"score": 4.7,
"commentCount": 12853,
"price": 60,
"address": "东城区景山前街4号",
"openTime": "08:30-17:00",
"tags": ["历史建筑", "博物馆"]
}
],
"totalCount": 358
}
}
解析器代码
python
# parser.py
from typing import List, Dict, Optional
import logging
class CtripParser:
@staticmethod
def parse_attraction_list(json_data: Dict) -> List[Dict]:
"""
解析景点列表JSON
Returns:
标准化的景点数据列表
"""
attractions = []
try:
poi_list = json_data.get('data', {}).get('poiList', [])
for poi in poi_list:
# 容错处理:字段缺失时填充默认值
attraction = {
'attraction_id': poi.get('poiId', ''),
'name': poi.get('poiName', '未知景点'),
'rating': float(poi.get('score', 0)),
'review_count': int(poi.get('commentCount', 0)),
'price': CtripParser._parse_price(poi.get('price')),
'address': poi.get('address', ''),
'open_time': poi.get('openTime', ''),
'tags': ','.join(poi.get('tags', [])),
'url': f"https://you.ctrip.com/sight/detail/{poi.get('poiId')}.html"
}
attractions.append(attraction)
logging.info(f"✓ 解析到 {len(attractions)} 个景点")
return attractions
except Exception as e:
logging.error(f"✗ 解析失败: {str(e)}")
return []
@staticmethod
def _parse_price(price_raw) -> float:
"""
价格字段清洗
处理情况:
- 数字: 60 → 60.0
- 字符串: "¥60" → 60.0
- 免费: "免费" → 0.0
- 缺失: None → -1.0
"""
if price_raw is None:
return -1.0
if isinstance(price_raw, (int, float)):
return float(price_raw)
# 去除货币符号和空格
price_str = str(price_raw).replace('¥', '').replace('元', '').strip()
if '免费' in price_str:
return 0.0
try:
return float(price_str)
except ValueError:
return -1.0
字段容错策略
| 异常情况 | 处理方式 |
|---|---|
| 评分字段缺失 | 填充0.0 |
| 价格为"电询" | 填充-1.0(后续可筛选) |
| 地址为空 | 保留空字符串 |
| 标签列表空 | 转为空字符串 |
7. 数据存储与导出(Storage)
存储方案设计
python
# storage.py
import pandas as pd
import sqlite3
from typing import List, Dict
import logging
from pathlib import Path
class DataStorage:
def __init__(self, data_dir: str = './data'):
self.data_dir = Path(data_dir)
self.data_dir.mkdir(exist_ok=True)
self.csv_path = self.data_dir / 'attractions.csv'
self.db_path = self.data_dir / 'attractions.db'
def save_to_csv(self, data: List[Dict], mode: str = 'a'):
"""
追加保存到CSV(支持断点续跑)
Args:
data: 景点数据列表
mode: 'w'覆盖写入, 'a'追加写入
"""
df = pd.DataFrame(data)
# 首次写入时添加表头
header = not self.csv_path.exists() if mode == 'a' else True
df.to_csv(
self.csv_path,
mode=mode,
index=False,
header=header,
encoding='utf-8-sig' # 兼容Excel打开
)
logging.info(f"✓ 已保存 {len(data)} 条数据到CSV")
def save_to_sqlite(self, data: List[Dict]):
"""
存储到SQLite(自动去重)
"""
conn = sqlite3.connect(self.db_path)
df = pd.DataFrame(data)
# 创建表(如不存在)
df.to_sql(
'attractions',
conn,
if_exists='append',
index=False
)
# 基于attraction_id去重
cursor = conn.cursor()
cursor.execute('''
DELETE FROM attractions
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM attractions
GROUP BY attraction_id
)
''')
conn.commit()
logging.info(f"✓ 已保存到SQLite,去重后共{cursor.rowcount}条")
conn.close()
def load_existing_ids(self) -> set:
"""
从数据库读取已采集的景点ID(用于断点续跑)
"""
if not self.db_path.exists():
return set()
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT attraction_id FROM attractions')
ids = {row[0] for row in cursor.fetchall()}
conn.close()
return ids
字段映射表
| 字段名 | 数据类型 | 示例值 | 说明 |
|---|---|---|---|
attraction_id |
VARCHAR(20) | "123456" | 主键 |
name |
VARCHAR(100) | "故宫博物院" | - |
rating |
FLOAT | 4.7 | 0-5分 |
review_count |
INTEGER | 12853 | - |
price |
FLOAT | 60.0 | -1表示未知 |
address |
TEXT | "..." | - |
open_time |
VARCHAR(50) | "08:30-17:00" | - |
tags |
TEXT | "历史,博物馆" | 逗号分隔 |
url |
TEXT | "https://..." | - |
8. 运行方式与结果展示
主程序整合
python
# main.py
import logging
from fetcher import CtripFetcher
from parser import CtripParser
from storage import DataStorage
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('logs/spider.log'),
logging.StreamHandler()
]
)
def crawl_city(city_id: int, city_name: str, max_pages: int = 5):
"""
爬取指定城市的景点数据
Args:
city_id: 城市ID
city_name: 城市名称(用于日志)
max_pages: 最大爬取页数
"""
fetcher = CtripFetcher()
parser = CtripParser()
storage = DataStorage()
# 加载已爬取ID(断点续跑)
existing_ids = storage.load_existing_ids()
all_data = []
for page in range(1, max_pages + 1):
logging.info(f"\n========== {city_name} 第{page}页 ==========")
# 1. 请求数据
json_data = fetcher.fetch_page(city_id, page)
if not json_data:
logging.error(f"第{page}页获取失败,跳过")
continue
# 2. 解析数据
attractions = parser.parse_attraction_list(json_data)
# 3. 过滤已存在的数据
new_attractions = [
a for a in attractions
if a['attraction_id'] not in existing_ids
]
if not new_attractions:
logging.info("本页数据已存在,跳过")
continue
# 4. 添加城市字段
for a in new_attractions:
a['city'] = city_name
all_data.extend(new_attractions)
# 5. 批量保存(每页立即写入,防止中断丢失)
storage.save_to_csv(new_attractions)
storage.save_to_sqlite(new_attractions)
logging.info(f"\n🎉 {city_name}爬取完成!共{len(all_data)}条新数据")
if __name__ == '__main__':
# 城市ID映射(需要预先查询)
CITIES = {
1: '北京',
2: '上海',
5: '成都'
}
for city_id, city_name in CITIES.items():
crawl_city(city_id, city_name, max_pages=3)
启动方式
bash
# 创建必要目录
mkdir -p data logs
# 运行爬虫
python main.py
输出示例
CSV文件(attractions.csv)前5行:
csv
attraction_id,name,rating,review_count,price,address,open_time,tags,url,city
123456,故宫博物院,4.7,12853,60.0,东城区景山前街4号,08:30-17:00,历史建筑,博物馆,https://...,北京
123457,天坛公园,4.6,8932,15.0,东城区天坛东里甲1号,06:00-22:00,古建筑,公园,https://...,北京
234567,外滩,4.8,15234,0.0,黄浦区中山东一路,全天,地标,夜景,https://...,上海
SQLite查询示例:
sql
-- 查看各城市景点数量
SELECT city, COUNT(*) as count
FROM attractions
GROUP BY city;
-- 查找高评分免费景点
SELECT name, rating, city
FROM attractions
WHERE price = 0 AND rating >= 4.5
ORDER BY rating DESC;
9. 常见问题与排错
问题1:返回403/429状态码
原因分析:
- 403:Headers不完整或UA被识别为爬虫
- 429:请求频率过高触发限流
解决方案:
python
# 增强Headers
headers = {
'User-Agent': 'Mozilla/5.0...', # 使用真实浏览器UA
'Referer': 'https://you.ctrip.com/',
'Cookie': 'your_cookie_here', # 部分接口需要登录态
'X-Requested-With': 'XMLHttpRequest'
}
# 加大延时
time.sleep(random.uniform(5, 8))
# 使用代理池(进阶)
proxies = {'http': 'http://proxy.com:8080'}
requests.get(url, proxies=proxies)
问题2:抓到的HTML是空壳
现象: Response长度只有几KB,没有景点数据
排查步骤:
- 确认是否为动态渲染页面(查看Network的XHR请求)
- 对比浏览器Headers和代码Headers(重点检查Cookie)
- 检查是否需要先访问列表页获取Token
案例: 某次爬取发现接口返回{code: -1, msg: "请先登录"},查看Cookie发现缺少_abtest参数。解决方法是先访问首页,提取Set-Cookie后再请求API。
问题3:JSON解析报错
错误信息:
json
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
原因: 返回内容不是JSON(可能是HTML错误页或被拦截)
调试技巧:
python
response = requests.get(url)
print(f"状态码: {response.status_code}")
print(f"Content-Type: {response.headers.get('Content-Type')}")
print(f"前100字符: {response.text[:100]}")
# 只有确认是JSON才解析
if 'application/json' in response.headers.get('Content-Type', ''):
data = response.json()
问题4:中文乱码
场景: CSV用Excel打开显示乱码
解决方案:
python
# 方案1:使用UTF-8-BOM编码(推荐)
df.to_csv('file.csv', encoding='utf-8-sig')
# 方案2:转为GBK(不推荐,可能丢失生僻字)
df.to_csv('file.csv', encoding='gbk', errors='ignore')
10. 进阶优化(可选)
10.1 异步并发加速
python
import asyncio
import aiohttp
async def fetch_async(session, city_id, page):
url = f"{BASE_URL}?cityId={city_id}&page={page}"
async with session.get(url) as response:
return await response.json()
async def crawl_city_async(city_id, max_pages):
async with aiohttp.ClientSession() as session:
tasks = [
fetch_async(session, city_id, page)
for page in range(1, max_pages + 1)
]
results = await asyncio.gather(*tasks)
return results
# 速度提升:同步2分钟 → 异步20秒
10.2 断点续跑机制
python
# 记录爬取进度
import json
def save_progress(city_id, page):
with open('progress.json', 'w') as f:
json.dump({'city_id': city_id, 'page': page}, f)
def load_progress():
try:
with open('progress.json') as f:
return json.load(f)
except FileNotFoundError:
return {'city_id': 1, 'page': 1}
# 使用示例
progress = load_progress()
for page in range(progress['page'], max_pages + 1):
# ... 爬取逻辑 ...
save_progress(city_id, page)
10.3 监控与报警
python
class SpiderMonitor:
def __init__(self):
self.success_count = 0
self.fail_count = 0
def report(self):
total = self.success_count + self.fail_count
success_rate = self.success_count / total if total > 0 else 0
if success_rate < 0.8:
# 发送钉钉/企业微信报警
send_alert(f"⚠️ 成功率仅{success_rate:.1%},请检查")
10.4 定时任务
bash
# crontab示例(每天凌晨2点更新)
0 2 * * * cd /path/to/project && python main.py >> logs/cron.log 2>&1
11. 总结与延伸
我们完成了什么?
✅ 逆向分析了携程景点列表API,绕过前端渲染限制
✅ 实现了健壮的请求层(重试、退避、频控)
✅ 构建了完整的数据流:采集 → 解析 → 清洗 → 存储
✅ 支持多城市批量爬取 + 断点续跑
实战收获
这个项目让我深刻体会到:爬虫不只是技术活,更是对抗与妥协的艺术。网站方设置反爬措施保护数据,我们需要在尊重规则的前提下,用技术手段获取公开信息。过程中踩过的坑------Cookie失效、频控封IP、数据结构突变------每一个都是宝贵的经验。
下一步可以做什么?
- 框架升级:迁移到Scrapy,利用其中间件机制处理反爬
- 分布式部署:使用Scrapy-Redis实现多机协同,日产百万级数据
- 智能化:接入GPT做景点描述总结、情感分析
- 产品化:开发Web界面,让非技术人员也能一键导出数据
延伸阅读
- 《Scrapy官方文档》:https://docs.scrapy.org/
- 《Python网络爬虫实战》(崔庆才)
- 《Web Scraping with Python》(Ryan Mitchell)
最后提醒: 爬虫是工具,用它来提升效率、做数据分析完全没问题,但千万别跨越法律红线。尊重数据所有者的权益,才能让这个技术长久健康地发展下去。
有问题欢迎在评论区交流,祝爬虫之路顺利!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)

免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!