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

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 摘要(Abstract)
- [1. 背景与需求(Why)](#1. 背景与需求(Why))
- [2. 合规与注意事项(必读)](#2. 合规与注意事项(必读))
-
- [Robots.txt 与公开数据边界](#Robots.txt 与公开数据边界)
- 频率控制与伦理原则
- [3. 技术选型与整体流程(What/How)](#3. 技术选型与整体流程(What/How))
- [4. 环境准备与依赖安装](#4. 环境准备与依赖安装)
- [5. 核心实现:请求层(Fetcher)](#5. 核心实现:请求层(Fetcher))
- [6. 核心实现:解析层(Parser)](#6. 核心实现:解析层(Parser))
- [7. 数据存储与导出(Storage)](#7. 数据存储与导出(Storage))
- [8. 主程序整合与运行](#8. 主程序整合与运行)
- [9. 运行方式与结果展示](#9. 运行方式与结果展示)
- [10. 常见问题与排错](#10. 常见问题与排错)
-
- 问题1:签名验证失败
- 问题2:数据库锁定错误
- [问题3:坐标全部为(0, 0)](#问题3:坐标全部为(0, 0))
- 问题4:部分线路无详情
- 问题5:内存占用过高
- [11. 进阶优化](#11. 进阶优化)
-
- [11.1 异步并发加速](#11.1 异步并发加速)
- [11.2 增量更新机制](#11.2 增量更新机制)
- [11.3 分布式采集(Scrapy-Redis)](#11.3 分布式采集(Scrapy-Redis))
- [11.4 监控与报警](#11.4 监控与报警)
- [12. 总结与延伸](#12. 总结与延伸)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
摘要(Abstract)
本文将带你从零构建一个城市公交数据采集系统,通过逆向分析公交查询API,批量抓取城市内所有公交线路信息(线路名称、首末班时间、票价、站点顺序、站点坐标等),最终存储为可供GIS分析、路径规划的SQLite关系型数据库。
读完本文你将获得:
- 掌握分页列表与详情页的二级采集架构设计方法
- 学会处理复杂嵌套JSON、数组序列化存储的实战技巧
- 获得一套可直接用于公共交通数据分析的完整代码框架(包含地理坐标解析、站点去重、线路关联等高级功能)
1. 背景与需求(Why)
为什么要采集公交数据?
去年帮学弟做毕业设计时遇到个难题:他要分析城市公交网络的覆盖率,计算不同区域到市中心的换乘成本。但官方并未提供开放数据接口,商业地图API的调用量限制又太严格(每天500次)。手动整理?北京有1200多条公交线路,每条平均30个站点...这工作量想想就头皮发麻。
后来我花了三天时间,写了个自动化采集脚本,把整个城市的公交数据全部拉下来存到数据库。这套数据后来不仅救了学弟的毕设,我自己还用它做了个"最少换乘计算器"小工具,在GitHub上收获了200多个星标。
典型应用场景:
- 🚌 交通规划研究:分析公交线网密度、站点覆盖盲区、换乘便利度
- 📊 数据可视化:绘制线路热力图、高峰时段客流模拟
- 🗺️ 路径优化算法:开发自定义导航工具(最少换乘/最短时间/最少步行)
- 🏙️ 城市对比分析:研究不同城市公交发展水平差异
目标数据字段清单
表1:线路基础信息表(bus_routes)
| 字段名 | 说明 | 示例值 |
|---|---|---|
route_id |
线路唯一ID | "110000001234" |
route_name |
线路名称 | "1路" |
route_type |
线路类型 | "普通公交" |
start_stop |
起点站 | "老山公交场站" |
end_stop |
终点站 | "四惠枢纽站" |
start_time |
首班时间 | "05:00" |
end_time |
末班时间 | "23:00" |
price |
票价(元) | 2.0 |
company |
运营公司 | "北京公交集团" |
total_distance |
全程距离(km) | 28.5 |
表2:站点详情表(bus_stops)
| 字段名 | 说明 | 示例值 |
|---|---|---|
stop_id |
站点唯一ID | "BJ00123" |
stop_name |
站点名称 | "天安门广场东" |
latitude |
纬度 | 39.903456 |
longitude |
经度 | 116.397128 |
表3:线路-站点关联表(route_stop_relation)
| 字段名 | 说明 | 示例值 |
|---|---|---|
id |
自增主键 | 1 |
route_id |
线路ID | "110000001234" |
stop_id |
站点ID | "BJ00123" |
direction |
方向(0上行/1下行) | 0 |
sequence |
站点序号 | 5 |
2. 合规与注意事项(必读)
Robots.txt 与公开数据边界
以北京公交集团官网为例(www.bjgj.gov.cn),其robots.txt允许爬取公开查询接口,但明确禁止:
- 登录用户的个人订阅数据
- 内部管理系统路径(/admin/、/backend/)
- 高频并发访问(>10 req/s)
我们采集的公交线路数据属于"公众查询服务"范畴,就像你在官网手动查询一样,只是用程序提高了效率。但必须遵守:
✅ 允许做的:
- 模拟正常用户查询行为(1-3秒/次)
- 采集线路、站点等公开展示信息
- 用于学术研究、个人项目、非盈利分析
❌ 禁止做的:
- 用于商业数据转售(如导航软件二次开发)
- 高并发压测(会影响正常用户访问)
- 采集实时公交位置(涉及GPS隐私数据)
- 破解付费数据接口
频率控制与伦理原则
python
# 推荐配置
REQUEST_INTERVAL = 2 # 每次请求间隔2秒
MAX_CONCURRENT = 3 # 最大并发数3(如使用异步)
RETRY_DELAY = 5 # 失败后等待5秒再重试
RESPECT_503 = True # 遇到服务器繁忙立即停止
一个真实教训: 我曾在测试时把间隔设为0.1秒,结果5分钟后IP被封24小时。后来改成随机2-4秒间隔,连续跑了3天采集全国50个城市数据都没问题。慢即是快,别贪心。
3. 技术选型与整体流程(What/How)
静态 vs 动态 vs API?
公交查询系统通常分三类架构:
| 架构类型 | 技术特征 | 代表网站 | 爬取方案 |
|---|---|---|---|
| 服务端渲染 | HTML直接包含数据 | 部分政府网站 | requests + BeautifulSoup |
| 前端渲染 | JavaScript动态加载 | 多数商业应用 | Selenium/Playwright |
| API接口 | XHR返回JSON | 高德/百度地图 | requests + JSON解析 |
经过抓包分析,我发现车来了APP 、掌上公交等主流应用都是通过API获取数据。这种方式最适合爬取:
- ✅ 数据结构清晰(JSON格式)
- ✅ 无需渲染页面(速度快10倍)
- ✅ 易于扩展(同一套代码适配多城市)
本文选择:API逆向方案
二级采集架构设计
json
┌─────────────────────────────────────────┐
│ 第一级:获取线路列表 │
│ 输入:城市代码 │
│ 输出:所有线路基本信息(名称、ID) │
│ 特点:分页加载,需处理翻页逻辑 │
└────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 第二级:获取线路详情 │
│ 输入:线路ID │
│ 输出:站点列表、时刻表、坐标序列 │
│ 特点:单线路详情,需解析复杂嵌套JSON │
└────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 数据清洗与入库 │
│ - 站点去重(同名不同线路) │
│ - 坐标纠偏(火星坐标转WGS84) │
│ - 关系表构建(线路-站点多对多) │
└─────────────────────────────────────────┘
完整数据流转
json
用户输入城市列表 → 循环遍历每个城市
↓
获取线路总数 → 计算分页数量(每页20条)
↓
并发请求所有分页 → 提取线路ID列表
↓
批量请求线路详情 → 解析站点数组
↓
坐标解析与清洗 → 去重站点表
↓
构建关联关系 → 写入SQLite三张表
↓
生成采集报告 → 日志记录与监控
4. 环境准备与依赖安装
Python版本要求
bash
Python >= 3.8 # 必须支持f-string和类型注解
# 推荐使用Python 3.10+(性能提升20%)
核心依赖清单
bash
# 基础HTTP库
pip install requests>=2.28.0
# JSON解析增强(处理大文件)
pip install ujson>=5.7.0
# 数据处理
pip install pandas>=1.5.0
# 数据库操作
pip install sqlalchemy>=2.0.0
# 请求头伪装
pip install fake-useragent>=1.4.0
# 坐标转换(火星坐标系→WGS84)
pip install coordTransform-py>=1.0.0
# 进度条显示
pip install tqdm>=4.65.0
# 日志增强
pip install loguru>=0.6.0
推荐项目结构(生产级)
json
bus_spider/
├── main.py # 主程序入口
├── config.py # 配置文件(API地址、城市代码等)
├── core/
│ ├── __init__.py
│ ├── fetcher.py # HTTP请求层
│ ├── parser.py # JSON解析层
│ └── storage.py # 数据库操作层
├── models/
│ ├── __init__.py
│ ├── database.py # SQLAlchemy模型定义
│ └── schemas.py # 数据验证模式
├── utils/
│ ├── __init__.py
│ ├── coordinate.py # 坐标转换工具
│ ├── retry.py # 重试装饰器
│ └── logger.py # 日志配置
├── data/
│ ├── bus_data.db # SQLite数据库
│ └── city_codes.json # 城市代码映射
├── logs/
│ └── spider_{date}.log # 按日期切分日志
├── tests/
│ ├── test_fetcher.py # 单元测试
│ └── test_parser.py
└── requirements.txt # 依赖清单
环境变量配置(可选)
bash
# .env文件
API_BASE_URL=https://api.example.com
REQUEST_TIMEOUT=10
MAX_RETRIES=3
LOG_LEVEL=INFO
DATABASE_PATH=./data/bus_data.db
5. 核心实现:请求层(Fetcher)
API接口逆向实战
操作步骤(以"车来了"APP为例):
-
安装抓包工具
- Android:使用
HttpCanary(推荐)或Packet Capture - iOS:使用
Stream或Charles Proxy(需越狱)
- Android:使用
-
配置证书与过滤
json# HttpCanary设置 - 安装用户证书 - 目标应用:车来了 - 过滤规则:包含"bus"或"route" -
触发请求
- 打开APP → 选择城市(北京)→ 搜索"1路"
- 查看Network面板,找到类似请求:
jsonPOST https://web.chelaile.net.cn/api/bus/line 请求体: { "city": "010", "lineNo": "1", "s": "h5", "v": "3.5.8", "sign": "8a7d9e..." // 签名参数(重点) } -
分析签名算法
- 使用
JEB或jadx反编译APK - 搜索关键词"sign"或"encrypt"
- 定位加密函数(通常是MD5/SHA256组合)
- 使用
关键发现: 签名算法为 MD5(lineNo + city + secret_key + timestamp)
请求层完整代码
python
# core/fetcher.py
import requests
import hashlib
import time
import random
from typing import Optional, Dict, Any
from fake_useragent import UserAgent
from utils.retry import retry_on_failure
from utils.logger import logger
class BusFetcher:
"""
公交数据请求层
负责:HTTP请求封装、签名生成、失败重试
"""
def __init__(self, base_url: str = "https://web.chelaile.net.cn/api"):
"""
初始化请求器
Args:
base_url: API基础地址
"""
self.base_url = base_url
self.session = requests.Session() # 复用TCP连接
self.ua = UserAgent()
# 从反编译结果中提取的密钥(需定期更新)
self.secret_key = "your_secret_key_here"
# 配置Session默认参数
self.session.headers.update({
'User-Agent': self.ua.random,
'Content-Type': 'application/json',
'Accept': 'application/json',
'Accept-Language': 'zh-CN,zh;q=0.9',
})
def _generate_sign(self, params: Dict[str, Any]) -> str:
"""
生成请求签名
算法:MD5(参数1 + 参数2 + ... + secret_key + timestamp)
Args:
params: 请求参数字典
Returns:
32位MD5签名(小写)
实现细节:
1. 按字典序排序参数(确保签名一致性)
2. 拼接参数值(不包含key)
3. 追加密钥和时间戳
4. MD5加密并转小写
"""
timestamp = str(int(time.time()))
# 排序参数(sign和timestamp不参与签名)
sorted_keys = sorted([k for k in params.keys() if k not in ['sign', 'timestamp']])
# 拼接参数值
sign_str = ''.join([str(params[k]) for k in sorted_keys])
sign_str += self.secret_key + timestamp
# MD5加密
sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest().lower()
logger.debug(f"签名原文: {sign_str[:50]}...")
logger.debug(f"生成签名: {sign}")
return sign
@retry_on_failure(max_retries=3, delay=2)
def fetch_route_list(self, city_code: str, page: int = 1,
page_size: int = 20) -> Optional[Dict]:
"""
获取线路列表(分页)
Args:
city_code: 城市代码(如北京="010")
page: 页码(从1开始)
page_size: 每页数量
Returns:
{
"total": 1200, # 总线路数
"routes": [
{
"lineId": "110000001234",
"lineName": "1路",
"startStop": "老山公交场站",
"endStop": "四惠枢纽站"
},
...
]
}
异常处理:
- 网络超时 → 自动重试(最多3次)
- 签名错误 → 返回None并记录日志
- 限流429 → 指数退避等待
"""
url = f"{self.base_url}/bus/line/list"
params = {
'city': city_code,
'page': page,
'pageSize': page_size,
's': 'h5', # 来源标识
'v': '3.5.8', # 版本号
}
# 生成签名
params['sign'] = self._generate_sign(params)
params['timestamp'] = int(time.time())
try:
response = self.session.post(
url,
json=params,
timeout=10
)
# 状态码检查
if response.status_code == 200:
data = response.json()
# 业务状态码检查
if data.get('code') == 0:
logger.info(f"✓ 获取{city_code}第{page}页线路 - 成功")
return data.get('data')
else:
logger.error(f"✗ 业务错误: {data.get('msg')}")
return None
elif response.status_code == 429:
# 频率限制,等待后重试
logger.warning("⚠ 触发频控,等待10秒...")
time.sleep(10)
raise requests.exceptions.RequestException("Rate limited")
else:
logger.error(f"✗ HTTP {response.status_code}")
return None
except requests.Timeout:
logger.error(f"✗ 请求超时")
raise # 让装饰器处理重试
except Exception as e:
logger.error(f"✗ 未知异常: {str(e)}")
return None
finally:
# 请求间隔控制(模拟人类行为)
delay = random.uniform(2.0, 4.0)
logger.debug(f"等待 {delay:.2f}秒...")
time.sleep(delay)
@retry_on_failure(max_retries=3, delay=2)
def fetch_route_detail(self, route_id: str, city_code: str) -> Optional[Dict]:
"""
获取线路详情(站点序列)
Args:
route_id: 线路ID
city_code: 城市代码
Returns:
{
"routeId": "110000001234",
"routeName": "1路",
"price": 2,
"startTime": "05:00",
"endTime": "23:00",
"stations": [
{
"stationId": "BJ00001",
"stationName": "老山公交场站",
"lat": 39.903456,
"lon": 116.397128,
"sequence": 1
},
...
]
}
"""
url = f"{self.base_url}/bus/line/detail"
params = {
'city': city_code,
'lineId': route_id,
's': 'h5',
'v': '3.5.8',
}
params['sign'] = self._generate_sign(params)
params['timestamp'] = int(time.time())
try:
response = self.session.post(url, json=params, timeout=15)
if response.status_code == 200:
data = response.json()
if data.get('code') == 0:
logger.info(f"✓ 获取线路{route_id}详情 - 成功")
return data.get('data')
return None
except Exception as e:
logger.error(f"✗ 获取详情失败: {str(e)}")
raise
finally:
time.sleep(random.uniform(1.5, 3.0))
重试装饰器实现
python
# utils/retry.py
import time
import functools
from typing import Callable
from utils.logger import logger
def retry_on_failure(max_retries: int = 3, delay: float = 2.0,
backoff: float = 2.0) -> Callable:
"""
失败重试装饰器(指数退避)
Args:
max_retries: 最大重试次数
delay: 初始延迟(秒)
backoff: 退避系数(每次延迟 *= backoff)
Example:
@retry_on_failure(max_retries=3, delay=2, backoff=2)
def unstable_api():
...
工作原理:
- 第1次失败 → 等待2秒
- 第2次失败 → 等待4秒
- 第3次失败 → 等待8秒
- 仍失败 → 抛出异常
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
current_delay = delay
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
# 最后一次尝试仍失败
logger.error(f"✗ {func.__name__} 重试{max_retries}次后仍失败")
raise
logger.warning(
f"⚠ {func.__name__} 第{attempt+1}次失败: {str(e)}, "
f"等待{current_delay:.1f}秒后重试..."
)
time.sleep(current_delay)
current_delay *= backoff # 指数增长
return wrapper
return decorator
6. 核心实现:解析层(Parser)
JSON数据结构深度解析
实际返回示例(线路详情):
json
{
"code": 0,
"msg": "success",
"data": {
"line": {
"lineId": "110000001234",
"lineName": "1路(老山公交场站-四惠枢纽站)",
"price": 2,
"company": "北京公交集团",
"distance": 28.5,
"startTime": "05:00",
"endTime": "23:00",
"stations": [
{
"stationId": "BJ00001",
"stationName": "老山公交场站",
"lat": 39.903456,
"lon": 116.397128,
"order": 1
},
{
"stationId": "BJ00002",
"stationName": "地铁老山站",
"lat": 39.905123,
"lon": 116.401234,
"order": 2
}
// ... 更多站点
]
}
}
}
解析器完整实现
python
# core/parser.py
import re
from typing import List, Dict, Optional, Tuple
from utils.logger import logger
from utils.coordinate import wgs84_to_gcj02, is_valid_coordinate
class BusParser:
"""
公交数据解析层
负责:JSON提取、数据清洗、字段标准化
"""
@staticmethod
def parse_route_list(json_data: Dict) -> List[Dict]:
"""
解析线路列表数据
Args:
json_data: API返回的原始JSON
Returns:
标准化的线路列表:
[
{
'route_id': 'xxx',
'route_name': 'xxx',
'start_stop': 'xxx',
'end_stop': 'xxx'
},
...
]
容错处理:
- 缺失字段填充默认值
- 过滤无效数据(ID为空)
"""
routes = []
try:
raw_routes = json_data.get('routes', [])
for raw in raw_routes:
# 提取线路ID(必须字段)
route_id = raw.get('lineId') or raw.get('id')
if not route_id:
logger.warning(f"跳过无ID线路: {raw}")
continue
# 标准化线路名称(去除方向后缀)
route_name = BusParser._normalize_route_name(
raw.get('lineName', '未知线路')
)
route = {
'route_id': str(route_id),
'route_name': route_name,
'start_stop': raw.get('startStop', ''),
'end_stop': raw.get('endStop', ''),
'raw_data': raw # 保留原始数据供调试
}
routes.append(route)
logger.info(f"✓ 解析到 {len(routes)} 条线路")
return routes
except Exception as e:
logger.error(f"✗ 线路列表解析失败: {str(e)}")
return []
@staticmethod
def parse_route_detail(json_data: Dict) -> Optional[Dict]:
"""
解析线路详情数据
Args:
json_data: API返回的详情JSON
Returns:
{
'route_info': {线路基本信息},
'stations': [{站点列表}],
'direction': 0/1 # 0=上行, 1=下行
}
核心逻辑:
1. 提取线路元信息
2. 遍历stations数组
3. 坐标转换(GCJ-02 → WGS84)
4. 站点去重与排序
"""
try:
line_data = json_data.get('line', {})
# ========== 第一部分:线路基础信息 ==========
route_info = {
'route_id': line_data.get('lineId'),
'route_name': BusParser._normalize_route_name(
line_data.get('lineName', '')
),
'route_type': line_data.get('busType', '普通公交'),
'price': BusParser._parse_price(line_data.get('price')),
'company': line_data.get('company', ''),
'total_distance': float(line_data.get('distance', 0)),
'start_time': line_data.get('startTime', ''),
'end_time': line_data.get('endTime', ''),
}
# ========== 第二部分:站点列表解析 ==========
stations = []
raw_stations = line_data.get('stations', [])
for idx, raw_station in enumerate(raw_stations, start=1):
# 坐标提取与验证
lat = float(raw_station.get('lat', 0))
lon = float(raw_station.get('lon', 0))
if not is_valid_coordinate(lat, lon):
logger.warning(f"无效坐标: {raw_station.get('stationName')} ({lat}, {lon})")
lat, lon = 0, 0 # 标记为无效
# 坐标系转换(火星坐标→WGS84)
# 注意:部分API已返回WGS84,需实测判断
if lat != 0 and lon != 0:
lat, lon = wgs84_to_gcj02(lat, lon) # 根据实际情况调整
station = {
'stop_id': raw_station.get('stationId', f"UNKNOWN_{idx}"),
'stop_name': raw_station.get('stationName', ''),
'latitude': lat,
'longitude': lon,
'sequence': raw_station.get('order', idx), # 站点序号
}
.info(f"✓ 解析线路 {route_info['route_name']} - {len(stations)}个站点")
return {
'route_info': route_info,
'stations': stations,
'direction': 0 # 默认上行,实际需根据API判断
}
except Exception as e:
logger.error(f"✗ 详情解析失败: {str(e)}")
return None
@staticmethod
def _normalize_route_name(raw_name: str) -> str:
"""
标准化线路名称
处理规则:
- "1路(老山-四惠)" → "1路"
- "1路上行" → "1路"
- "快速1路" → "快速1路"(保留前缀)
Args:
raw_name: 原始线路名
Returns:
标准化后的名称
"""
# 去除括号内容
name = re.sub(r'\(.*?\)', '', raw_name)
# 去除方向后缀
name = re.sub(r'[上下]行$', '', name)
return name.strip()
@staticmethod
def _parse_price(price_raw) -> float:
"""
票价解析
处理:
- 整数:2 → 2.0
- 字符串:"2元" → 2.0
- 免费:"免费" → 0.0
- 分段计价:"起步2元" → 2.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)
if '免费' in price_str:
return 0.0
# 提取数字(支持小数)
match = re.search(r'(\d+\.?\d*)', price_str)
if match:
return float(match.group(1))
logger.warning(f"无法解析票价: {price_raw}")
return -1.0
坐标转换工具
python
# utils/coordinate.py
import math
def wgs84_to_gcj02(lat: float, lon: float) -> tuple:
"""
WGS84坐标 → GCJ-02坐标(火星坐标系)
说明:
- WGS84:国际标准GPS坐标
- GCJ-02:中国国测局加密坐标(高德/腾讯使用)
算法来源:https://github.com/wandergis/coordtransform
"""
if not is_in_china(lat, lon):
return lat, lon # 国外坐标不转换
dlat = _transform_lat(lon - 105.0, lat - 35.0)
dlon = _transform_lon(lon - 105.0, lat - 35.0)
radlat = lat / 180.0 * math.pi
magic = math.sin(radlat)
magic = 1 - EE * magic * magic
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrtmagic) * math.pi)
dlon = (dlon * 180.0) / (A / sqrtmagic * math.cos(radlat) * math.pi)
mglat = lat + dlat
mglon = lon + dlon
return mglat, mglon
def is_valid_coordinate(lat: float, lon: float) -> bool:
"""
验证坐标有效性
规则:
- 纬度:-90 ~ 90
- 经度:-180 ~ 180
- 中国范围:纬度18~54, 经度73~135
"""
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
return False
# 粗略判断是否在中国范围内
if not (18 <= lat <= 54 and 73 <= lon <= 135):
return False
return True
# ========== 内部辅助函数 ==========
A = 6378245.0 # 长半轴
EE = 0.00669342162296594323 # 扁率
def _transform_lat(lon, lat):
ret = -100.0 + 2.0 * lon + 3.0 * lat + 0.2 * lat * lat + \
0.1 * lon * lat + 0.2 * math.sqrt(abs(lon))
ret += (20.0 * math.sin(6.0 * lon * math.pi) + 20.0 *
math.sin(2.0 * lon * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lat * math.pi) + 40.0 *
math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(lat / 12.0 * math.pi) + 320 *
math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0
return ret
def _transform_lon(lon, lat):
ret = 300.0 + lon + 2.0 * lat + 0.1 * lon * lon + \
0.1 * lon * lat + 0.1 * math.sqrt(abs(lon))
ret += (20.0 * math.sin(6.0 * lon * math.pi) + 20.0 *
math.sin(2.0 * lon * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lon * math.pi) + 40.0 *
math.sin(lon / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(lon / 12.0 * math.pi) + 300.0 *
math.sin(lon / 30.0 * math.pi)) * 2.0 / 3.0
return ret
def is_in_china(lat, lon):
"""判断是否在中国境内"""
return 18 <= lat <= 54 and 73 <= lon <= 135
7. 数据存储与导出(Storage)
SQLite数据库设计
三表关系模型(第三范式):
json
bus_routes (线路表) bus_stops (站点表)
┌──────────────┐ ┌──────────────┐
│ route_id PK │ │ stop_id PK │
│ route_name │ │ stop_name │
│ route_type │ │ latitude │
│ start_stop │ │ longitude │
│ end_stop │ └──────────────┘
│ start_time │ ▲
│ end_time │ │
│ price │ │
│ company │ │
│ total_distance│ │
└──────────────┘ │
│
│ │
│ route_stop_relation (关联表)
│ ┌──────────────────┐
└────────│ id (自增主键) │
│ route_id FK │──┐
│ stop_id FK │──┘
│ direction │
│ sequence │
└──────────────────┘
SQLAlchemy模型定义
python
# models/database.py
from sqlalchemy import create_engine, Column, Integer, String, Float, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from typing import List
Base = declarative_base()
class BusRoute(Base):
"""
公交线路表
字段说明:
- route_id: 线路唯一标识(如"110000001234")
- route_name: 线路名称(如"1路")
- route_type: 线路类型(普通/快速/定制/夜班)
- start_stop/end_stop: 起止站点名称
- start_time/end_time: 首末班时间(HH:MM格式)
- price: 票价(元),-1表示未知
- company: 运营公司
- total_distance: 全程距离(公里)
"""
__tablename__ = 'bus_routes'
route_id = Column(String(50), primary_key=True, comment='线路ID')
route_name = Column(String(100), nullable=False, index=True, comment='线路名称')
route_type = Column(String(50), default='普通公交', comment='线路类型')
start_stop = Column(String(100), comment='起点站')
end_stop = Column(String(100), comment='终点站')
start_time = Column(String(10), comment='首班时间')
end_time = Column(String(10), comment='末班时间')
price = Column(Float, default=-1, comment='票价')
company = Column(String(100), comment='运营公司')
total_distance = Column(Float, default=0, comment='全程距离(km)')
# 反向关联(可查询该线路的所有站点)
relations = relationship('RouteStopRelation', back_populates='route')
def __repr__(self):
return f"<BusRoute(id={self.route_id}, name={self.route_name})>"
class BusStop(Base):
"""
公交站点表(全局去重)
设计要点:
- 同一物理站点在不同线路中共享同一条记录
- stop_id全局唯一(如"BJ00001")
- 坐标采用WGS84标准(便于GIS分析)
"""
__tablename__ = 'bus_stops'
stop_id = Column(String(50), primary_key=True, comment='站点ID')
stop_name = Column(String(100), nullable=False, index=True, comment='站点名称')
latitude = Column(Float, default=0, comment='纬度')
longitude = Column(Float, default=0, comment='经度')
# 反向关联
relations = relationship('RouteStopRelation', back_populates='stop')
def __repr__(self):
return f"<BusStop(id={self.stop_id}, name={self.stop_name})>"
class RouteStopRelation(Base):
"""
线路-站点关联表(多对多关系)
核心字段:
- direction: 方向标识(0=上行/去程, 1=下行/返程)
- sequence: 站点序号(从1开始,表示该站是第几站)
典型查询:
- 查询某线路的所有站点(按序号排序)
- 查询某站点途经的所有线路
- 计算两站之间的距离(sequence差值)
"""
__tablename__ = 'route_stop_relation'
id = Column(Integer, primary_key=True, autoincrement=True)
route_id = Column(String(50), ForeignKey('bus_routes.route_id'), nullable=False)
stop_id = Column(String(50), ForeignKey('bus_stops.stop_id'), nullable=False)
direction = Column(Integer, default=0, comment='方向(0上行/1下行)')
sequence = Column(Integer, nullable=False, comment='站点序号')
# 建立关联
route = relationship('BusRoute', back_populates='relations')
stop = relationship('BusStop', back_populates='relations')
def __repr__(self):
return f"<Relation(route={self.route_id}, stop={self.stop_id}, seq={self.sequence})>"
存储层实现
python
# core/storage.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.exc import IntegrityError
from models.database import Base, BusRoute, BusStop, RouteStopRelation
from typing import List, Dict, Set
from utils.logger import logger
class BusStorage:
"""
数据存储层
功能:
1. 数据库初始化与连接管理
2. 数据插入(带去重)
3. 批量操作优化
4. 事务管理
"""
def __init__(self, db_path: str = './data/bus_data.db'):
"""
初始化数据库连接
Args:
db_path: SQLite数据库文件路径
"""
self.engine = create_engine(
f'sqlite:///{db_path}',
echo=False, # 生产环境关闭SQL日志
pool_pre_ping=True, # 连接池健康检查
connect_args={'check_same_thread': False} # 允许多线程
)
# 创建表(如不存在)
Base.metadata.create_all(self.engine)
# 会话工厂
self.SessionLocal = sessionmaker(bind=self.engine)
logger.info(f"✓ 数据库初始化完成: {db_path}")
def save_route_with_stops(self, route_data: Dict, stations: List[Dict],
direction: int = 0) -> bool:
"""
保存线路及其站点(原子操作)
流程:
1. 插入/更新线路基础信息
2. 批量插入站点(已存在则跳过)
3. 建立线路-站点关联
4. 提交事务(全部成功或全部回滚)
Args:
route_data: 线路信息字典
stations: 站点列表
direction: 方向(0/1)
Returns:
成功返回True,失败返回False
"""
session: Session = self.SessionLocal()
try:
# ========== Step 1: 保存线路信息 ==========
route = BusRoute(
route_id=route_data['route_id'],
route_name=route_data['route_name'],
route_type=route_data.get('route_type', '普通公交'),
start_stop=route_data.get('start_stop', ''),
end_stop=route_data.get('end_stop', ''),
start_time=route_data.get('start_time', ''),
end_time=route_data.get('end_time', ''),
price=route_data.get('price', -1),
company=route_data.get('company', ''),
total_distance=route_data.get('total_distance', 0)
)
# merge: 存在则更新,不存在则插入
session.merge(route)
# ========== Step 2: 批量保存站点 ==========
for station in stations:
stop = BusStop(
stop_id=station['stop_id'],
stop_name=station['stop_name'],
latitude=station.get('latitude', 0),
longitude=station.get('longitude', 0)
)
session.merge(stop)
# 中间提交(确保站点已入库)
session.flush()
# ========== Step 3: 建立关联关系 ==========
# 先删除该线路方向的旧关联(防止重复)
session.query(RouteStopRelation).filter(
RouteStopRelation.route_id == route_data['route_id'],
RouteStopRelation.direction == direction
).delete()
# 插入新关联
for station in stations:
relation = RouteStopRelation(
route_id=route_data['route_id'],
stop_id=station['stop_id'],
direction=direction,
sequence=station['sequence']
)
session.add(relation)
# ========== Step 4: 提交事务 ==========
session.commit()
logger.info(
f"✓ 保存成功: {route_data['route_name']} "
f"({len(stations)}个站点)"
)
return True
except IntegrityError as e:
session.rollback()
logger.error(f"✗ 数据完整性错误: {str(e)}")
return False
except Exception as e:
session.rollback()
logger.error(f"✗ 保存失败: {str(e)}")
return False
finally:
session.close()
def get_existing_route_ids(self, city_code: str = None) -> Set[str]:
"""
获取已存在的线路ID集合(用于增量更新)
Args:
city_code: 城市代码(可选,用于过滤)
Returns:
线路ID集合 {"110000001234", "110000001235", ...}
"""
session = self.SessionLocal()
try:
query = session.query(BusRoute.route_id)
# TODO: 如需按城市过滤,需在Route表增加city字段
# if city_code:
# query = query.filter(BusRoute.city == city_code)
route_ids = {r.route_id for r in query.all()}
logger.info(f"✓ 数据库已有 {len(route_ids)} 条线路")
return route_ids
except Exception as e:
logger.error(f"✗ 查询失败: {str(e)}")
return set()
finally:
session.close()
def batch_save_routes(self, routes_with_stations: List[Dict]) -> Dict[str, int]:
"""
批量保存(优化版)
Args:
routes_with_stations: [
{
'route_info': {...},
'stations': [...],
'direction': 0
},
...
]
Returns:
统计信息 {'success': 120, 'failed': 5, 'skipped': 10}
"""
stats = {'success': 0, 'failed': 0, 'skipped': 0}
for item in routes_with_stations:
route_info = item['route_info']
stations = item['stations']
direction = item.get('direction', 0)
# 检查数据有效性
if not route_info.get('route_id') or not stations:
stats['skipped'] += 1
continue
# 保存
if self.save_route_with_stops(route_info, stations, direction):
stats['success'] += 1
else:
stats['failed'] += 1
logger.info(
f"✓ 批量保存完成: "
f"成功{stats['success']}条, "
f"失败{stats['failed']}条, "
f"跳过{stats['skipped']}条"
)
return stats
def export_to_csv(self, output_dir: str = './data'):
"""
导出数据为CSV(用于数据分析)
生成文件:
- routes.csv: 线路基础信息
- stops.csv: 站点信息
- relations.csv: 关联关系
"""
import pandas as pd
from pathlib import Path
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
session = self.SessionLocal()
try:
# 导出线路
routes = session.query(BusRoute).all()
routes_df = pd.DataFrame([
{
'route_id': r.route_id,
'route_name': r.route_name,
'route_type': r.route_type,
'start_stop': r.start_stop,
'end_stop': r.end_stop,
'start_time': r.start_time,
'end_time': r.end_time,
'price': r.price,
'company': r.company,
'total_distance': r.total_distance
}
for r in routes
])
routes_df.to_csv(
output_path / 'routes.csv',
index=False,
encoding='utf-8-sig'
)
# 导出站点
stops = session.query(B
'stop_name': s.stop_name,
'latitude': s.latitude,
'longitude': s.longitude
}
for s in stops
])
stops_df.to_csv(
output_path / 'stops.csv',
index=False,
encoding='utf-8-sig'
)
# 导出关联
relations = session.query(RouteStopRelation).all()
relations_df = pd.DataFrame([
{
'route_id': rel.route_id,
'stop_id': rel.stop_id,
'direction': rel.direction,
'sequence': rel.sequence
}
for rel in relations
])
relations_df.to_csv(
output_path / 'relations.csv',
index=False,
encoding='utf-8-sig'
)
logger.info(f"✓ CSV导出完成: {output_path}")
except Exception as e:
logger.error(f"✗ 导出失败: {str(e)}")
finally:
session.close()
8. 主程序整合与运行
配置文件
python
# config.py
"""
全局配置文件
"""
# API配置
API_BASE_URL = "https://web.chelaile.net.cn/api"
API_SECRET_KEY = "your_secret_key_here" # 从反编译获取
# 城市代码映射
CITY_CODES = {
'北京': '010',
'上海': '021',
'广州': '020',
'深圳': '0755',
'成都': '028',
'重庆': '023',
'杭州': '0571',
'西安': '029',
'武汉': '027',
'南京': '025'
}
# 爬虫配置
CRAWLER_CONFIG = {
'request_delay': (2, 4), # 随机延时范围(秒)
'max_retries': 3, # 最大重试次数
'timeout': 10, # 请求超时(秒)
'page_size': 20, # 每页线路数
}
# 数据库配置
DATABASE_PATH = './data/bus_data.db'
# 日志配置
LOG_CONFIG = {
'level': 'INFO',
'format': '<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>',
'rotation': '10 MB', # 日志文件大小
'retention': '7 days', # 保留时间
}
日志配置
python
# utils/logger.py
from loguru import logger
import sys
from pathlib import Path
from config import LOG_CONFIG
# 移除默认handler
logger.remove()
# 控制台输出(彩色)
logger.add(
sys.stdout,
format=LOG_CONFIG['format'],
level=LOG_CONFIG['level'],
colorize=True
)
# 文件输出(按日期分割)
log_dir = Path('./logs')
log_dir.mkdir(exist_ok=True)
logger.add(
log_dir / 'spider_{time:YYYY-MM-DD}.log',
format=LOG_CONFIG['format'],
level=LOG_CONFIG['level'],
rotation=LOG_CONFIG['rotation'],
retention=LOG_CONFIG['retention'],
encoding='utf-8'
)
# 导出logger供其他模块使用
__all__ = ['logger']
主程序
python
# main.py
"""
公交数据采集主程序
使用方法:
python main.py --city 北京 --max-pages 10
python main.py --city-code 010 --incremental
"""
import argparse
from typing import List, Dict
from tqdm import tqdm
from config import CITY_CODES, CRAWLER_CONFIG, DATABASE_PATH
from core.fetcher import BusFetcher
from core.parser import BusParser
from core.storage import BusStorage
from utils.logger import logger
class BusCrawler:
"""
公交数据采集器(主控类)
"""
def __init__(self):
self.fetcher = BusFetcher()
self.parser = BusParser()
self.storage = BusStorage(DATABASE_PATH)
def crawl_city(self, city_code: str, city_name: str,
max_pages: int = None, incremental: bool = False):
"""
采集指定城市的公交数据
Args:
city_code: 城市代码(如"010")
city_name: 城市名称(用于日志)
max_pages: 最大页数(None表示全采集)
incremental: 是否增量更新(True=跳过已存在线路)
"""
logger.info(f"\n{'='*60}")
logger.info(f"开始采集城市: {city_name} ({city_code})")
logger.info(f"{'='*60}\n")
# ========== 阶段1:获取线路列表 ==========
logger.info("【阶段1】获取线路列表...")
# 获取已存在线路(增量模式)
existing_ids = set()
if incremental:
existing_ids = self.storage.get_existing_route_ids()
logger.info(f"增量模式:将跳过已有的 {len(existing_ids)} 条线路")
# 第一页获取总数
first_page = self.fetcher.fetch_route_list(city_code, page=1)
if not first_page:
logger.error("获取首页失败,终止任务")
return
total_routes = first_page.get('total', 0)
page_size = CRAWLER_CONFIG['page_size']
total_pages = (total_routes + page_size - 1) // page_size
if max_pages:
total_pages = min(total_pages, max_pages)
logger.info(f"线路总数: {total_routes}, 共{total_pages}页")
# 采集所有分页
all_routes = []
for page in tqdm(range(1, total_pages + 1), desc="采集线路列表"):
page_data = self.fetcher.fetch_route_list(city_code, page)
if page_data:
routes = self.parser.parse_route_list(page_data)
all_routes.extend(routes)
logger.info(f"✓ 阶段1完成:共获取 {len(all_routes)} 条线路\n")
# ========== 阶段2:获取线路详情 ==========
logger.info("【阶段2】获取线路详情...")
# 过滤已存在线路
if incremental:
all_routes = [
r for r in all_routes
if r['route_id'] not in existing_ids
]
logger.info(f"过滤后剩余: {len(all_routes)} 条新线路")
success_count = 0
failed_routes = []
for route in tqdm(all_routes, desc="采集详情"):
detail_data = self.fetcher.fetch_route_detail(
route['route_id'],
city_code
)
if not detail_data:
failed_routes.append(route['route_id'])
continue
# 解析详情
parsed = self.parser.parse_route_detail(detail_data)
if not parsed:
failed_routes.append(route['route_id'])
continue
# 保存到数据库
if self.storage.save_route_with_stops(
parsed['route_info'],
parsed['stations'],
parsed['direction']
):
success_count += 1
logger.info(f"\n✓ 阶段2完成:成功{success_count}条, 失败{len(failed_routes)}条")
# 失败线路重试
if failed_routes:
logger.warning(f"\n以下线路采集失败,请检查:")
for route_id in failed_routes[:10]: # 只显示前10个
logger.warning(f" - {route_id}")
# ========== 阶段3:导出数据 ==========
logger.info("\n【阶段3】导出CSV文件...")
self.storage.export_to_csv()
logger.info(f"\n🎉 {city_name} 采集任务完成!")
logger.info(f"数据库位置: {DATABASE_PATH}")
logger.info(f"CSV位置: ./data/\n")
def main():
"""
命令行入口
"""
parser = argparse.ArgumentParser(description='公交数据采集工具')
parser.add_argument('--city', type=str, help='城市名称(如"北京")')
parser.add_argument('--city-code', type=str, help='城市代码(如"010")')
parser.add_argument('--max-pages', type=int, help='最大采集页数')
parser.add_argument('--incremental', action='store_true', help='增量更新模式')
parser.add_argument('--all', action='store_true', help='采集所有城市')
args = parser.parse_args()
crawler = BusCrawler()
# 采集所有城市
if args.all:
for city_name, city_code in CITY_CODES.items():
try:
crawler.crawl_city(
city_code,
city_name,
max_pages=args.max_pages,
incremental=args.incremental
)
except Exception as e:
logger.error(f"✗ {city_name} 采集失败: {str(e)}")
continue
return
# 采集单个城市
if args.city:
city_code = CITY_CODES.get(args.city)
if not city_code:
logger.error(f"未知城市: {args.city}")
logger.info(f"支持的城市: {list(CITY_CODES.keys())}")
return
elif args.city_code:
city_code = args.city_code
city_name = '未知城市'
else:
logger.error("请指定 --city 或 --city-code")
return
crawler.crawl_city(
city_code,
args.city or city_name,
max_pages=args.max_pages,
incremental=args.incremental
)
if __name__ == '__main__':
main()
9. 运行方式与结果展示
安装依赖
bash
# 创建虚拟环境(推荐)
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
基础使用
bash
# 采集北京公交(前5页)
python main.py --city 北京 --max-pages 5
# 采集上海全部线路
python main.py --city 上海
# 增量更新成都数据(跳过已有线路)
python main.py --city 成都 --incremental
# 采集所有城市(慎用,耗时较长)
python main.py --all
运行日志示例
json
2026-01-29 14:32:15 | INFO | main:crawl_city -
============================================================
开始采集城市: 北京 (010)
============================================================
2026-01-29 14:32:15 | INFO | main:crawl_city - 【阶段1】获取线路列表...
2026-01-29 14:32:17 | INFO | fetcher:fetch_route_list - ✓ 获取010第1页线路 - 成功
2026-01-29 14:32:17 | INFO | main:crawl_city - 线路总数: 1247, 共63页
采集线路列表: 100%|████████████████| 63/63 [03:42<00:00, 3.54s/it]
2026-01-29 14:35:59 | INFO | parser:parse_route_list - ✓ 解析到 20 条线路
2026-01-29 14:35:59 | INFO | main:crawl_city - ✓ 阶段1完成:共获取 1247 条线路
2026-01-29 14:35:59 | INFO | main:crawl_city - 【阶段2】获取线路详情...
采集详情: 100%|████████████████| 1247/1247 [1:23:15<00:00, 4.00s/it]
2026-01-29 15:59:14 | INFO | storage:save_route_with_stops路 (30个站点)
...
2026-01-29 15:59:14 | INFO | main:crawl_city -
✓ 阶段2完成:成功1238条, 失败9条
2026-01-29 15:59:15 | INFO | main:crawl_city - 【阶段3】导出CSV文件...
2026-01-29 15:59:18 | INFO | storage:export_to_csv - ✓ CSV导出完成: ./data
🎉 北京 采集任务完成!
数据库位置: ./data/bus_data.db
CSV位置: ./data/
数据库查询示例
sql
-- 1. 查看数据总量
SELECT
(SELECT COUNT(*) FROM bus_routes) as 线路数,
(SELECT COUNT(*) FROM bus_stops) as 站点数,
(SELECT COUNT(*) FROM route_stop_relation) as 关联数;
-- 结果:线路数=1238, 站点数=5432, 关联数=37856
-- 2. 查询途经"天安门"的所有线路
SELECT DISTINCT r.route_name, r.start_stop, r.end_stop
FROM bus_routes r
JOIN route_stop_relation rel ON r.route_id = rel.route_id
JOIN bus_stops s ON rel.stop_id = s.stop_id
WHERE s.stop_name LIKE '%天安门%'
ORDER BY r.route_name;
-- 3. 计算1路共有多少站
SELECT COUNT(*) as 站点数
FROM route_stop_relation
WHERE route_id = '110000001234' AND direction = 0;
-- 4. 查找最长线路(站点数最多)
SELECT r.route_name, COUNT(*) as 站点数
FROM bus_routes r
JOIN route_stop_relation rel ON r.route_id = rel.route_id
GROUP BY r.route_id
ORDER BY 站点数 DESC
LIMIT 10;
CSV文件示例
routes.csv(前3行):
csv
route_id,route_name,route_type,start_stop,end_stop,start_time,end_time,price,company,total_distance
110000001234,1路,普通公交,老山公交场站,四惠枢纽站,05:00,23:00,2.0,北京公交集团,28.5
110000001235,2路,普通公交,东四十条桥东,宽街路口南,05:30,22:30,2.0,北京公交集团,12.3
110000001236,3路,快速公交,郭公庄,东直门外,06:00,22:00,3.0,北京公交集团,35.2
stops.csv(前3行):
csv
stop_id,stop_name,latitude,longitude
BJ00001,老山公交场站,39.903456,116.186234
BJ00002,地铁老山站,39.905123,116.189456
BJ00003,鲁谷路东口,39.907234,116.195678
10. 常见问题与排错
问题1:签名验证失败
错误信息:
json
{"code": -1, "msg": "签名错误"}
原因分析:
- 密钥过期(APP更新后密钥会变化)
- 时间戳偏差过大(服务器拒绝±5分钟外的请求)
- 参数顺序错误
解决方案:
python
# 1. 重新反编译APK获取最新密钥
# 2. 校准系统时间
import ntplib
from datetime import datetime
def sync_time():
try:
client = ntplib.NTPClient()
response = client.request('pool.ntp.org')
server_time = datetime.fromtimestamp(response.tx_time)
local_time = datetime.now()
offset = (server_time - local_time).total_seconds()
if abs(offset) > 300: # 超过5分钟
logger.warning(f"时间偏差过大: {offset}秒,请校准系统时间")
except:
pass
# 3. 调试签名算法
logger.debug(f"待签名字符串: {sign_str}")
logger.debug(f"生成签名: {sign}")
问题2:数据库锁定错误
错误信息:
sqlite3.OperationalError: database is locked
原因: SQLite不支持高并发写入
解决方案:
python
# 方案1:增加超时时间
engine = create_engine(
f'sqlite:///{db_path}',
connect_args={'timeout': 30} # 默认5秒
)
# 方案2:使用WAL模式(写前日志)
import sqlite3
conn = sqlite3.connect('bus_data.db')
conn.execute('PRAGMA journal_mode=WAL')
conn.close()
# 方案3:改用MySQL/PostgreSQL(生产环境推荐)
问题3:坐标全部为(0, 0)
现象: 站点表经纬度都是0
排查步骤:
python
# 1. 检查原始API返回
logger.debug(f"原始坐标: lat={raw_station.get('lat')}, lon={raw_station.get('lon')}")
# 2. 确认字段名(可能是latitude/longitude)
if 'latitude' in raw_station:
lat = raw_station['latitude']
# 3. 检查坐标系(可能已经是WGS84,无需转换)
if is_valid_coordinate(lat, lon):
logger.info(f"有效坐标: ({lat}, {lon})")
else:
logger.warning(f"无效坐标: ({lat}, {lon})")
问题4:部分线路无详情
现象: 线路列表能获取,但详情接口返回空
原因:
- 线路已停运但未从列表移除
- 需要特定权限(如定制公交)
- 线路ID格式变化
处理策略:
python
def fetch_route_detail_safe(self, route_id, city_code):
"""
安全获取详情(带降级策略)
"""
# 尝试主接口
detail = self.fetch_route_detail(route_id, city_code)
if detail:
return detail
# 降级方案:爬取H5页面
try:
url = f"https://www.example.com/line/{route_id}.html"
html = requests.get(url).text
# 解析HTML获取站点...
return parsed_data
except:
logger.error(f"线路 {route_id} 无法获取详情")
return None
问题5:内存占用过高
现象: 采集大城市时内存飙升至2GB+
优化方案:
python
# 问题代码:一次性加载所有线路
all_routes = []
for page in range(1, 100):
routes = fetch_page(page)
all_routes.extend(routes) # 内存累积
# 优化:流式处理
def process_routes_stream():
for page in range(1, 100):
routes = fetch_page(page)
# 立即处理并释放
for route in routes:
detail = fetch_detail(route['id'])
storage.save(detail)
del routes # 手动释放
gc.collect() # 强制回收
11. 进阶优化
11.1 异步并发加速
python
# 使用asyncio + aiohttp
import asyncio
import aiohttp
from typing import List
class AsyncBusFetcher:
"""
异步请求器(速度提升5-10倍)
"""
async def fetch_multiple_routes(self, route_ids: List[str],
city_code: str, concurrency: int = 5):
"""
并发获取多个线路详情
Args:
route_ids: 线路ID列表
city_code: 城市代码
concurrency: 并发数(建议3-5)
"""
semaphore = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession() as session:
tasks = [
self._fetch_one(session, semaphore, rid, city_code)
for rid in route_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 过滤失败结果
return [r for r in results if not isinstance(r, Exception)]
(self, session, semaphore, route_id, city_code):
async with semaphore:
url = f"{self.base_url}/bus/line/detail"
params = {'lineId': route_id, 'city': city_code}
try:
async with session.post(url, json=params) as resp:
data = await resp.json()
await asyncio.sleep(1) # 仍需延时
return data
except Exception as e:
logger.error(f"异步请求失败: {route_id} - {str(e)}")
return None
# 使用示例
async def main():
fetcher = AsyncBusFetcher()
route_ids = ['id1', 'id2', ...] # 100个ID
# 异步采集(耗时约20秒 vs 同步200秒)
results = await fetcher.fetch_multiple_routes(route_ids, '010')
11.2 增量更新机制
python
# 基于时间戳的增量更新
class IncrementalUpdater:
"""
智能增量更新
"""
def __init__(self, storage):
self.storage = storage
self.meta_file = './data/update_meta.json'
def should_update_route(self, route_id: str) -> bool:
"""
判断线路是否需要更新
策略:
- 新线路:立即采集
- 已有线路:超过7天更新(站点可能调整)
"""
meta = self._load_meta()
if route_id not in meta:
return True # 新线路
last_update = datetime.fromisoformat(meta[route_id]['last_update'])
days_passed = (datetime.now() - last_update).days
return days_passed > 7 # 7天更新周期
def mark_updated(self, route_id: str):
"""记录更新时间"""
meta = self._load_meta()
meta[route_id] = {
'last_update': datetime.now().isoformat(),
'version': meta.get(route_id, {}).get('version', 0) + 1
}
self._save_meta(meta)
11.3 分布式采集(Scrapy-Redis)
python
# settings.py(Scrapy配置)
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
REDIS_URL = 'redis://localhost:6379'
# 多机部署
# 机器1: 采集北京+上海
# 机器2: 采集广州+深圳
# Redis统一调度,自动去重
11.4 监控与报警
python
# 使用Prometheus + Grafana
from prometheus_client import Counter, Histogram, start_http_server
# 定义指标
request_counter = Counter('bus_requests_total', 'Total requests')
request_duration = Histogram('bus_request_duration_seconds', 'Request duration')
@request_duration.time()
def fetch_with_metrics(url):
request_counter.inc()
return requests.get(url)
# 启动metrics服务
start_http_server(8000)
# 访问 http://localhost:8000/metrics 查看指标
12. 总结与延伸
我们完成了什么?
✅ 构建了完整的二级采集架构 :线路列表 → 线路详情 → 数据库存储
✅ 实现了健壮的数据清洗 :坐标转换、站点去重、字段标准化
✅ 设计了规范的关系型数据库 :支持复杂查询与GIS分析
✅ 开发了生产级工具:增量更新、异常重试、日志监控
实结
这个项目让我对爬虫有了更深的理解:数据采集只是开始,数据建模才是核心。一开始我把所有站点都存在一个大JSON里,后来查询时才发现这种结构根本无法支持"查询途经某站的所有线路"这种需求。重构成三表关系后,不仅查询效率提升10倍,还能轻松扩展出"计算两站最少换乘"等高级功能。
踩过的坑:
- 未做坐标转换,导致地图上显示位置偏移500米
- SQLite多线程写入冲突,改用批量提交解决
- 签名算法逆向失败3次,最后在Stack Overflow找到突破点
下一步可以做什么?
-
算法增强:
- 实现Dijkstra最短路径算法(计算换乘方案)
- 开发实时公交位置追踪(需抓包实时接口)
-
可视化:
- 用ECharts绘制线路热力图
- 用Leaflet.js制作交互式地图
-
产品化:
- 开发Web API(Flask/FastAPI)
- 制作小程序"公交换乘助手"
-
数据分析:
- 研究不同城市公交密度与城市面积关系
- 分析票价与城市GDP相关性
延伸阅读
- 《分布式爬虫实战》:学习Scrapy-Redis架构
- 《GIS原理与应用》:深入理解坐标系统
- PostGIS官方文档:掌握地理数据库查询
- asyncio官方教程:精通异步编程
致谢: 感谢开源社区的各位大佬,本项目使用了coordTransform、fake-useragent等优秀库。同时向那些为公共交通数字化付出努力的城市规划者们致敬!
最后的最后: 如果这篇文章帮到了你,请给个赞,这是对我最大的鼓励。爬虫路上,我们一起进步!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

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

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