Python爬虫实战:城市公交数据采集实战:从多线路分页到结构化站点序列(附CSV导出 + SQLite持久化存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: 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为例):

  1. 安装抓包工具

    • Android:使用HttpCanary(推荐)或Packet Capture
    • iOS:使用StreamCharles Proxy(需越狱)
  2. 配置证书与过滤

    json 复制代码
    # HttpCanary设置
    - 安装用户证书
    - 目标应用:车来了
    - 过滤规则:包含"bus"或"route"
  3. 触发请求

    • 打开APP → 选择城市(北京)→ 搜索"1路"
    • 查看Network面板,找到类似请求:
    json 复制代码
    POST https://web.chelaile.net.cn/api/bus/line
    请求体:
    {
      "city": "010",
      "lineNo": "1",
      "s": "h5",
      "v": "3.5.8",
      "sign": "8a7d9e..."  // 签名参数(重点)
    }
  4. 分析签名算法

    • 使用JEBjadx反编译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找到突破点

下一步可以做什么?

  1. 算法增强

    • 实现Dijkstra最短路径算法(计算换乘方案)
    • 开发实时公交位置追踪(需抓包实时接口)
  2. 可视化

    • 用ECharts绘制线路热力图
    • 用Leaflet.js制作交互式地图
  3. 产品化

    • 开发Web API(Flask/FastAPI)
    • 制作小程序"公交换乘助手"
  4. 数据分析

    • 研究不同城市公交密度与城市面积关系
    • 分析票价与城市GDP相关性

延伸阅读

  • 《分布式爬虫实战》:学习Scrapy-Redis架构
  • 《GIS原理与应用》:深入理解坐标系统
  • PostGIS官方文档:掌握地理数据库查询
  • asyncio官方教程:精通异步编程

致谢: 感谢开源社区的各位大佬,本项目使用了coordTransform、fake-useragent等优秀库。同时向那些为公共交通数字化付出努力的城市规划者们致敬!

最后的最后: 如果这篇文章帮到了你,请给个赞,这是对我最大的鼓励。爬虫路上,我们一起进步!

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


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

相关推荐
2301_811232981 小时前
使用Python进行PDF文件的处理与操作
jvm·数据库·python
深蓝海拓2 小时前
海康 MV 相机几种Bayer RG像素格式的处理
笔记·python·qt·学习·pyqt
少年强则国强2 小时前
anaconda安装配置pycharm
ide·python·pycharm
m0_561359672 小时前
自动化与脚本
jvm·数据库·python
盐真卿2 小时前
python第五部分:文件操作
前端·数据库·python
多打代码2 小时前
2026.1.29 复原ip地址 & 子集 & 子集2
开发语言·python
人工智能AI技术2 小时前
【Agent从入门到实践】47 与前端系统集成:通过API对接,实现前端交互
人工智能·python
qq_192779872 小时前
如何用FastAPI构建高性能的现代API
jvm·数据库·python
癫狂的兔子2 小时前
【bug】【Python】pandas中的DataFrame.to_excel()和ExcelWriter的区别
python·bug