Python爬虫实战:快递物流轨迹采集实战:从公开查询到时间线可视化(附CSV导出 + SQLite持久化存储)!

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

㊗️爬虫难度指数:⭐⭐

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

全文目录:

🌟 开篇语

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

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈

💕订阅后更新会优先推送,按目录学习更高效💯~

摘要(Abstract)

本文将手把手教你构建一个快递物流轨迹采集与分析系统 ,通过合法调用公开查询接口,批量追踪快递包裹的实时状态(揽收→运输→派送→签收),并以时间线形式持久化存储。重点讲解如何在严格遵守合规要求的前提下,实现低频、非侵入式的数据采集。

读完本文你将获得:

  • 掌握快递查询API的逆向分析与加密参数生成方法(以快递100为例)
  • 学会设计符合隐私保护要求的采集策略(低频轮询、敏感信息脱敏)
  • 获得一套完整的物流数据存储模型(支持多快递公司、状态机追踪、异常预警)

1. 背景与需求(Why)

为什么要采集物流轨迹?

去年双十一期间,我同时下了20多个订单,每天要打开淘宝、京东、拼多多查看物流进度。更烦的是,有些包裹显示"已签收",但我根本没收到------怀疑被快递员放错地方了,却找不到证据。当时我就想:如果能自动记录所有包裹的物流节点,形成不可篡改的时间线,就能作为维权凭证了

于是我花了一周时间,开发了这个物流追踪工具。不仅解决了自己的痛点,还帮朋友的淘宝店监控了200多个订单的发货状态,及时发现了3个"揽收超时"的异常单。

典型应用场景:

  • 📦 个人用户:集中管理所有快递,避免漏收、被冒领
  • 🏪 电商卖家:监控发货时效,降低客诉率
  • 📊 数据分析:研究快递公司服务质量(时效、丢件率)
  • ⚖️ 法律维权:保存完整物流证据链

目标数据字段清单

表1:快递基础信息表(express_orders)

字段名 说明 示例值
tracking_no 快递单号 "SF1234567890"
company_code 快递公司代码 "shunfeng"
company_name 快递公司名称 "顺丰速运"
sender_name 寄件人(脱敏) "张*"
receiver_name 收件人(脱敏) "李*"
receiver_phone 收件电话后4位 "1234"
current_status 当前状态 "派送中"
is_signed 是否签收 True/False
created_at 创建时间 "2026-01-20 10:30:00"

表2:物流节点表(tracking_records)

字段名 说明 示例值
id 自增主键 1
tracking_no 快递单号(外键) "SF1234567890"
time 节点时间 "2026-01-21 08:30:00"
status 状态描述 "快件已到达【北京朝阳分拣中心】"
location 地点 "北京市朝阳区"
operator 操作人(脱敏) "张**"
sequence 节点序号 3

2. 合规与注意事项(必读重点)

快递信息的法律属性

⚠️ 核心原则:快递单号属于个人隐私数据!

根据《个人信息保护法》第28条:

处理敏感个人信息应当取得个人的单独同意;法律、行政法规规定处理敏感个人信息应当取得书面同意的,从其规定。

快递信息涉及的敏感数据:

  • 收件人姓名、电话、地址(可推断居住信息)
  • 寄件人信息(可能暴露购物习惯)
  • 物流轨迹(可推断行动轨迹)

合规采集边界

允许做的:

  • 查询自己的快递(有合法授权)
  • 查询用户主动提交的单号(电商卖家场景)
  • 低频查询(每单号间隔≥10分钟)
  • 查询公开接口(快递100、快递鸟等)

禁止做的:

  • 批量遍历单号(如暴力枚举SF0000001~SF9999999)
  • 未授权查询他人快递
  • 将数据用于商业转售、用户画像
  • 高频轮询(每分钟查询同一单号)
  • 存储完整姓名、电话、地址

数据脱敏策略

python 复制代码
# 姓名脱敏
"张三" → "张*"
"欧阳娜娜" → "欧阳**"

# 电话脱敏
"13812345678" → "138****5678"

# 地址脱敏
"北京市朝阳区xx路xx号xx室" → "北京市朝阳区"

推荐查询频率

python 复制代码
# 基于快递状态的智能频控
QUERY_INTERVALS = {
    '已签收': None,          # 不再查询
    '派送中': 30 * 60,       # 30分钟
    '运输中': 2 * 3600,      # 2小时
    '已揽收': 4 * 3600,      # 4小时
    '待揽收': 6 * 3600,      # 6小时
}

3. 技术选型与整体流程(What/How)

快递查询接口对比

平台 接口类型 免费额度 签名复杂度 推荐度
快递100 REST API 100次/天 中(MD5+KEY) ⭐⭐⭐⭐⭐
快递鸟 HTTP POST 3000次/天 高(数据加密) ⭐⭐⭐⭐
菜鸟裹裹 APP私有协议 无限制 极高(需逆向) ⭐⭐⭐
京东物流 官方API 需企业认证 低(OAuth) ⭐⭐

选择快递100的原因:

  • 支持200+快递公司
  • 文档清晰,无需复杂逆向
  • 有免费套餐(个人学习够用)
  • 返回JSON格式标准

数据采集流程

json 复制代码
用户提交单号 → 识别快递公司
    ↓
检查缓存(避免重复查询)
    ↓
调用快递100 API → 解析JSON
    ↓
数据清洗与脱敏
    ↓
写入数据库(订单表 + 节点表)
    ↓
状态变更检测 → 触发通知
    ↓
定时任务轮询未签收订单

状态机设计

json 复制代码
[待揽收] → [已揽收] → [运输中] → [到达派件城市]
                                       ↓
                             [派送中] → [已签收]
                                       ↓
                                  [异常] ← [问题件]

4. 环境准备与依赖安装

Python版本要求

bash 复制代码
Python >= 3.9  # 支持类型注解和字典合并运算符

核心依赖清单

bash 复制代码
# HTTP请求
pip install requests>=2.28.0

# 数据处理
pip install pandas>=2.0.0

# 数据库ORM
pip install sqlalchemy>=2.0.0

# 定时任务
pip install apscheduler>=3.10.0

# 加密算法
pip install pycryptodome>=3.18.0

# 配置管理
pip install python-decouple>=3.8

# 日志增强
pip install loguru>=0.7.0

# 时间处理
pip install python-dateutil>=2.8.0

# 通知推送(可选)
pip install requests  # 钉钉/企业微信webhook

项目结构(生产级)

json 复制代码
express_tracker/
├── main.py                  # 主程序入口
├── config.py                # 配置文件
├── .env                     # 环境变量(密钥、API KEY)
├── core/
│   ├── __init__.py
│   ├── api_client.py        # 快递100 API封装
│   ├── company_detector.py  # 快递公司识别
│   ├── data_cleaner.py      # 数据清洗与脱敏
│   └── scheduler.py         # 定时轮询任务
├── models/
│   ├── __init__.py
│   ├── database.py          # 数据库模型
│   └── schemas.py           # 数据验证
├── services/
│   ├── __init__.py
│   ├── tracker.py           # 核心追踪逻辑
│   └── notifier.py          # 通知服务
├── utils/
│   ├── __init__.py
│   ├── crypto.py            # 签名生成
│   ├── logger.py            # 日志配置
│   └── cache.py             # 缓存管理
├── data/
│   ├── express.db           # SQLite数据库
│   └── cache/               # 查询缓存
├── logs/
│   └── tracker_{date}.log
└── tests/
    ├── test_api.py
    └── test_tracker.py

5. 核心实现:API客户端层

快递100 API分析

官方文档地址: https://www.kuaidi100.com/openapi/

免费接口示例:

http 复制代码
POST https://poll.kuaidi100.com/poll/query.do
Content-Type: application/x-www-form-urlencoded

customer=YOUR_KEY&sign=MD5签名&param={"com":"sh1234567890"}

签名算法:

json 复制代码
sign = MD5(param + key + customer).toUpperCase()

API客户端完整实现

python 复制代码
# core/api_client.py
import requests
import hashlib
import json
import time
from typing import Optional, Dict, List
from decouple import config
from utils.logger import logger
from utils.cache import Cache

class Kuaidi100Client:
    """
    快递100 API客户端
    
    功能:
    1. 请求签名生成
    2. API调用封装
    3. 响应解析与错误处理
    4. 查询缓存管理
    """
    
    def __init__(self):
        """
        初始化客户端
        
        环境变量配置(.env文件):
        KUAIDI100_CUSTOMER=your_customer_key
        KUAIDI100_KEY=your_api_key
        """
        # 从环境变量读取密钥
        self.customer = config('KUAIDI100_CUSTOMER')
        self.key = config('KUAIDI100_KEY')
        
        # API端点
        self.query_url = 'https://poll.kuaidi100.com/poll/query.do'
        self.auto_detect_url = 'https://www.kuaidi100.com/autonumber/autoComNum'
        
        # 请求配置
        self.timeout = 10
        self.session = requests.Session()
        
        # 缓存管理器(避免重复查询)
        self.cache = Cache(ttl=600)  # 10分钟缓存
        
        logger.info("✓ 快递100客户端初始化完成")
    
    def _generate_sign(self, param: str) -> str:
        """
        生成请求签名
        
        算法:MD5(param + key + customer).upper()
        
        Args:
            param: JSON格式的查询参数
            
        Returns:
            32位大写MD5签名
            
        Example:
            param = '{"com":"shunfeng","num":"SF1234567890"}'
            sign = MD5(param + key + customer)
        """
        # 拼接签名原文
        sign_str = param + self.key + self.customer
        
        # MD5加密
        md5_hash = hashlib.md5(sign_str.encode('utf-8')).hexdigest()
        
        # 转大写(快递100要求)
        sign = md5_hash.upper()
        
        logger.debug(f"签名原文长度: {len(sign_str)}")
        logger.debug(f"生成签名: {sign[:16]}...")  # 只显示前16位
        
        return sign
    
    def auto_detect_company(self, tracking_no: str) -> List[Dict]:
        """
        自动识别快递公司
        
        原理:基于单号规则匹配(如顺丰以SF开头)
        
        Args:
            tracking_no: 快递单号
            
        Returns:
            可能的快递公司列表:
            [
                {"comCode": "shunfeng", "name": "顺丰速运"},
                {"comCode": "yuantong", "name": "圆通速递"}
            ]
            
        注意:
        - 部分单号可能匹配多个公司(如数字单号)
        - 返回结果按匹配度排序,取第一个即可
        """
        try:
            # 发起识别请求
            response = self.session.post(
                self.auto_detect_url,
                data={'text': tracking_no},
                timeout=self.timeout
            )
            
            if response.status_code == 200:
                data = response.json()
                
                # 检查返回结果
                if data.get('auto') and len(data['auto']) > 0:
                    companies = data['auto']
                    logger.info(
                        f"✓ 识别单号 {tracking_no} → "
                        f"{companies[0]['comCode']} ({companies[0]['name']})"
                    )
                    return companies
                else:
                    logger.warning(f"⚠ 无法识别单号: {tracking_no}")
                    return []
            else:
                logger.error(f"✗ 识别接口返回 {response.status_code}")
                return []
                
        except Exception as e:
            logger.error(f"✗ 公司识别失败: {str(e)}")
            return []
    
    def query_tracking(self, company_code: str, tracking_no: str,
                       phone_last4: Optional[str] = None,
                       use_cache: bool = True) -> Optional[Dict]:
        """
        查询快递物流信息(核心方法)
        
        Args:
            company_code: 快递公司代码(如"shunfeng")
            tracking_no: 快递单号
            phone_last4: 收件人手机号后4位(部分公司需要)
            use_cache: 是否使用缓存
            
        Returns:
            {
                "message": "ok",
                "state": "3",  // 0在途 1揽收 2疑难 3签收 4退签 5派件 6退回
                "ischeck": "1",  // 是否签收
                "com": "shunfeng",
                "nu": "SF1234567890",
                "data": [
                    {
                        "time": "2026-01-21 08:30:00",
                        "ftime": "2026-01-21 08:30:00",
                        "context": "快件已到达【北京朝阳分拣中心】",
                        "location": "北京市"
                    },
                    ...
                ]
            }
            
        缓存策略:
        - 已签收订单:缓存24小时
        - 未签收订单:缓存10分钟
        """
        # ========== 第一步:检查缓存 ==========
        cache_key = f"{company_code}:{tracking_no}"
        
        if use_cache:
            cached_data = self.cache.get(cache_key)
            if cached_data:
                logger.info(f"✓ 命中缓存: {tracking_no}")
                return cached_data
        
        # ========== 第二步:构造请求参数 ==========
        param_dict = {
            'com': company_code,
            'num': tracking_no
        }
        
        # 部分快递公司需要手机号后4位(如顺丰)
        if phone_last4:
            param_dict['phone'] = phone_last4
        
        # 转JSON字符串
        param = json.dumps(param_dict, ensure_ascii=False)
        
        # 生成签名
        sign = self._generate_sign(param)
        
        # ========== 第三步:发起HTTP请求 ==========
        try:
            response = self.session.post(
                self.query_url,
                data={
                    'customer': self.customer,
                    'sign': sign,
                    'param': param
                },
                timeout=self.timeout
            )
            
            # ========== 第四步:解析响应 ==========
            if response.status_code == 200:
                data = response.json()
                
                # 检查业务状态码
                if data.get('message {len(data.get('data', []))}条记录")
                    
                    # 写入缓存
                    ttl = 86400 if data.get('ischeck') == '1' else 600
                    self.cache.set(cache_key, data, ttl=ttl)
                    
                    return data
                    
                elif data.get('returnCode') == '500':
                    # 单号不存在或已过期
                    logger.warning(f"⚠ 查询失败: {data.get('message')}")
                    return None
                    
                elif data.get('returnCode') == '501':
                    # 需要手机号验证
                    logger.warning(f"⚠ 需要手机号后4位: {tracking_no}")
                    return None
                    
                else:
                    logger.error(f"✗ API返回错误: {data}")
                    return None
                    
            else:
                logger.error(f"✗ HTTP {response.status_code}")
                return None
                
        except requests.Timeout:
            logger.error(f"✗ 请求超时: {tracking_no}")
            return None
            
        except Exception as e:
            logger.error(f"✗ 查询异常: {str(e)}")
            return None
        
        finally:
            # 频率控制(避免触发限流)
            time.sleep(1)  # 每次查询间隔1秒
    
    def batch_query(self, tracking_list: List[Dict]) -> List[Dict]:
        """
        批量查询(带频控)
        
        Args:
            tracking_list: [
                {"company": "shunfeng", "no": "SF123", "phone": "1234"},
                ...
            ]
            
        Returns:
            查询结果列表
        """
        results = []
        
        for idx, item in enumerate(tracking_list, 1):
            logger.info(f"正在查询 {idx}/{len(tracking_list)}: {item['no']}")
            
            result = self.query_tracking(
                item['company'],
                item['no'],
                item.get('phone')
            )
            
            if result:
                results.append(result)
            
            # 批量查询时增加延时(避免频控)
            if idx < len(tracking_list):
                time.sleep(3)  # 间隔3秒
        
        logger.info(f"✓ 批量查询完成: 成功{len(results)}/{len(tracking_list)}")
        return results

缓存管理器

python 复制代码
# utils/cache.py
import json
import time
from pathlib import Path
from typing import Any, Optional

class Cache:
    """
    简单的文件缓存管理器
    
    功能:
    - 减少API调用次数
    - 加速查询响应
    - 离线模式支持
    """
    
    def __init__(self, cache_dir: str = './data/cache', ttl: int = 600):
        """
        Args:
            cache_dir: 缓存目录
            ttl: 默认过期时间(秒)
        """
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.default_ttl = ttl
    
    def _get_cache_path(self, key: str) -> Path:
        """
        生成缓存文件路径
        
        策略:对key做MD5,避免特殊字符
        """
        import hashlib
        key_hash = hashlib.md5(key.encode()).hexdigest()
        return self.cache_dir / f"{key_hash}.json"
    
    def get(self, key: str) -> Optional[Any]:
        """
        读取缓存
        
        Returns:
            缓存数据(未过期)或None
        """
        cache_file = self._get_cache_path(key)
        
        if not cache_file.exists():
            return None
        
        try:
            with open(cache_file, 'r', encoding='utf-8') as f:
                cache_data = json.load(f)
            
            # 检查过期时间
            if time.time() > cache_data['expire_at']:
                cache_file.unlink()  # 删除过期缓存
                return None
            
            return cache_data['value']
            
        except:
            return None
    
    def set(self, key: str, value: Any, ttl: Optional[int] = None):
        """
        写入缓存
        """
        cache_file = self._get_cache_path(key)
        
        expire_at = time.time() + (ttl or self.default_ttl)
        
        cache_data = {
            'key': key,
            'value': value,
            'expire_at': expire_at
        }
        
        with open(cache_file, 'w', encoding='utf-8') as f:
            json.dump(cache_data, f, ensure_ascii=False, indent=2)

6. 核心实现:数据清洗与脱敏层

数据脱敏策略

python 复制代码
# core/data_cleaner.py
import re
from typing import Dict, List, Optional
from utils.logger import logger

class DataCleaner:
    """
    数据清洗与脱敏处理器
    
    职责:
    1. 个人信息脱敏(姓名、电话、地址)
    2. 数据标准化(时间格式、状态映射)
    3. 敏感词过滤
    """
    
    @staticmethod
    def mask_name(name: str) -> str:
        """
        姓名脱敏
        
        规则:
        - 2字:保留姓氏,名字用*替代(张三 → 张*)
        - 3字:保留姓氏,名字用**替代(李小明 → 李**)
        - 4字及以上:保留前2字(欧阳娜娜 → 欧阳**)
        
        Args:
            name: 原始姓名
            
        Returns:
            脱敏后姓名
        """
        if not name or len(name) == 0:
            return "未知"
        
        length = len(name)
        
        if length == 1:
            return name  # 单字姓名不脱敏(如"丁")
        elif length == 2:
            return name[0] + '*'
        elif length == 3:
            return name[0] + '**'
        else:
            # 复姓(4字及以上)
            return name[:2] + '*' * (length - 2)
    
    @staticmethod
    def mask_phone(phone: str) -> str:
        """
        手机号脱敏
        
        规则:
        - 保留前3位和后4位
        - 中间用****替代(13812345678 → 138****5678)
        
        Args:
            phone: 原始手机号
            
        Returns:
            脱敏后手机号
        """
        if not phone:
            return ""
        
        # 提取数字
        digits = re.sub(r'\D', '', phone)
        
        if len(digits) == 11:
            # 标准手机号
            return digits[:3] + '****' + digits[-4:]
        elif len(digits) >= 7:
            # 座机或其他(保留前3位和后4位)
            return digits[:3] + '****' + digits[-4:]
        else:
            # 长度不足,全部脱敏
            return '*' * len(digits)
    
    @staticmethod
    def mask_address(address: str) -> str:
        """
        地址脱敏
        
        规则:
        - 只保留到区/县级别
        - 去除详细门牌号、楼栋单元
        
        Example:
            "北京市朝阳区建国路93号万达广场12号楼3单元501室"
            → "北京市朝阳区"
        
        Args:
            address: 原始地址
            
        Returns:
            脱敏后地址
        """
        if not address:
            return ""
        
        # 正则匹配省市区
        patterns = [
            r'(.*?[省市].*?[市区县])',  # 标准格式
            r'(.*?[省市])',              # 只有省市
        ]
        
        for pattern in patterns:
            match = re.search(pattern, address)
            if match:
                return match.group(1)
        
        # 无法解析,返回前20字符
        return address[:20] + '...' if len(address) > 20 else address
    
    @staticmethod
    def extract_location(context: str) -> Optional[str]:
        """
        从物流描述中提取地点
        
        Example:
            "快件已到达【北京朝阳分拣中心】" → "北京市"
            "您的快件已签收,签收人:本人签收" → None
        
        Args:
            context: 物流状态描述
            
        Returns:
            提取的地点(省市级别)
        """
        # 匹配中括号内的地点
        bracket_match = re.search(r'【(.*?)】', context)
        if bracket_match:
            location_text = bracket_match.group(1)
            
            # 提取省市
            city_match = re.search(r'(.*?[省市])', location_text)
            if city_match:
                return city_match.group(1)
        
        # 直接匹配省市关键词
        city_pattern = r'(北京|上海|天津|重庆|.*?省.*?市|.*?市)'
        match = re.search(city_pattern, context)
        if match:
            return match.group(1)
        
        return None
    
    @staticmethod
    def normalize_status(state_code: str) -> Dict[str, str]:
        """
        状态码标准化
        
        快递100状态码映射:
        0 - 在途中
        1 - 已揽收
        2 - 疑难件
        3 - 已签收
        4 - 退签
        5 - 派送中
        6 - 退回
        
        Args:
            state_code: 原始状态码(字符串)
            
        Returns:
            {
                "code": "3",
                "name": "已签收",
                "category": "completed"  # pending/in_transit/delivering/completed/exception
            }
        """
        status_map = {
            '0': {'name': '运输中', 'category': 'in_transit'},
            '1': {'name': '已揽收', 'category': 'in_transit'},
            '2': {'name': '疑难件', 'category': 'exception'},
            '3': {'name': '已签收', 'category': 'completed'},
            '4': {'name': '退签', 'category': 'exception'},
            '5': {'name': '派送中', 'category': 'delivering'},
            '6': {'name': '退回', 'category': 'exception'},
        }
        
        info = status_map.get(state_code, {'name': '未知', 'category': 'pending'})
        
        return {
            'code': state_code,
            'name': info['name'],
            'category': info['category']
        }
    
    @staticmethod
    def clean_tracking_records(raw_data: List[Dict]) -> List[Dict]:
        """
        清洗物流节点数据
        
        处理:
        1. 时间格式标准化
        2. 提取地点信息
        3. 去重(相同时间+相同描述)
        4. 排序(时间倒序)
        
        Args:
            raw_data: API返回的原始data数组
            
        Returns:
            清洗后的记录列表
        """
        cleaned_records = []
        seen_records = set()  # 用于去重
        
        for idx, record in enumerate(raw_data):
            # 时间标准化
            time_str = record.get('ftime') or record.get('time', '')
            
            # 状态描述
            context = record.get('context', '').strip()
            
            # 去重检查
            dedup_key = f"{time_str}:{context}"
            if dedup_key in seen_records:
                logger.debug(f"跳过重复记录: {time_str}")
                continue
            seen_records.add(dedup_key)
            
            # 提取地点
            location = DataCleaner.extract_location(context)
            
            cleaned_record = {
                'time': time_str,
                'context': context,
                'location': location or '',
                'sequence': len(raw_data) - idx,  # 倒序编号(最新的序号最大)
            }
            
            cleaned_records.append(cleaned_record)
        
        # 按时间倒序排序(最新的在前)
        cleaned_records.sort(key=lambda x: x['time'], reverse=True)
        
        logger.info(f"✓ 清洗完成: {len(cleaned_records)}条记录(去重前{len(raw_data)}条)")
        
        return cleaned_records

7. 数据存储层(Database)

SQLAlchemy模型定义

python 复制代码
# models/database.py
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from datetime import datetime

Base = declarative_base()

class ExpressOrder(Base):
    """
    快递订单主表
    
    存储快递的基础信息和当前状态
    """
    __tablename__ = 'express_orders'
    
    # 主键:快递单号(全局唯一)
    tracking_no = Column(String(50), primary_key=True, comment='快递单号')
    
    # 快递公司信息
    company_code = Column(String(50), nullable=False, index=True, comment='快递公司代码')
    company_name = Column(String(100), comment='快递公司名称')
    
    # 收寄件人信息(脱敏后)
    sender_name = Column(String(50), comment='寄件人(脱敏)')
    receiver_name = Column(String(50), comment='收件人(脱敏)')
    receiver_phone = Column(String(20), comment='收件电话后4位')
    
    # 当前状态
    current_status = Column(String(50), comment='当前状态')
    status_code = Column(String(10), comment='状态码')
    status_category = Column(String(20), comment='状态类别')
    is_signed = Column(Boolean, default=False, comment='是否签收')
    
    # 时间信息
    created_at = Column(DateTime, default=datetime.now, comment='创建时间')
    last_update = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment='最后更新')
    signed_at = Column(DateTime, nullable=True, comment='签收时间')
    
    # 查询配置
    need_phone = Column(Boolean, default=False, comment='是否需要手机号验证')
    phone_last4 = Column(String(4), comment='手机号后4位(查询用)')
    
    # 统计信息
    query_count = Column(Integer, default=0, comment='查询次数')
    record_count = Column(Integer, default=0, comment='物流节点数')
    
    # 备注
    remark = Column(Text, comment='备注信息')
    
    # 反向关联:一个订单对应多个物流节点
    tracking_records = relationship('TrackingRecord', back_populates='order', cascade='all, delete-orphan')
    
    def __repr__(self):
        return f"<ExpressOrder(no={self.tracking_no}, status={self.current_status})>"


class TrackingRecord(Base):
    """
    物流节点记录表
    
    存储每个快递的物流轨迹节点
    """
    __tablename__ = 'tracking_records'
    
    # 主键
    id = Column(Integer, primary_key=True, autoincrement=True)
    
    # 外键:关联订单表
    tracking_no = Column(String(50), ForeignKey('express_orders.tracking_no'), nullable=False)
    
    # 节点信息
    time = Column(DateTime, nullable=False, comment='节点时间')
    context = Column(Text, nullable=False, comment='状态描述')
    location = Column(String(100), comment='地点')
    
    # 序号(用于排序)
    sequence = Column(Integer, comment='节点序号')
    
    # 时间戳(用于去重)
    created_at = Column(DateTime, default=datetime.now, comment='记录创建时间')
    
    # 反向关联
    order = relationship('ExpressOrder', back_populates='tracking_records')
    
    def __repr__(self):
        return f"<TrackingRecord(no={self.tracking_no}, time={self.time})>"
    
    class Meta:
        # 联合索引(快速查询某单号的所有记录)
        indexes = [
            ('tracking_no', 'time'),
        ]

数据库操作封装

python 复制代码
# models/database.py (续)
from sqlalchemy.orm import Session
from typing import List, Optional, Dict
from datetime import datetime
from utils.logger import logger

class DatabaseManager:
    """
    数据库操作管理器
    
    功能:
    1. 订单CRUD操作
    2. 物流节点批量插入
    3. 状态更新检测
    4. 查询统计
    """
    
    def __init__(self, db_path: str = './data/express.db'):
        """
        初始化数据库连接
        
        Args:
            db_path: SQLite数据库文件路径
        """
        self.engine = create_engine(
            f'sqlite:///{db_path}',
            echo=False,
            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_order_with_records(self, order_data: Dict, 
                                 records: List[Dict]) -> bool:
        """
        保存订单及其物流记录(原子操作)
        
        流程:
        1. 检查订单是否存在
        2. 新订单:插入订单表
        3. 已存在:更新状态
        4. 批量插入新的物流节点(去重)
        5. 提交事务
        
        Args:
            order_data: 订单信息字典
            records: 物流节点列表
            
        Returns:
            成功返回True
        """
        session: Session = self.SessionLocal()
        
        try:
            tracking_no = order_data['tracking_no']
            
            # ========== Step 1: 查询或创建订单 ==========
            order = session.query(ExpressOrder).filter(
                ExpressOrder.tracking_no == tracking_no
            ).first()
            
            if order is None:
                # 新订单
                order = ExpressOrder(**order_data)
                session.add(order)
                logger.info(f"✓ 新建订单: {tracking_no}")
            else:
                # 更新现有订单
                for key, value in order_data.items():
                    if key != 'tracking_no':  # 主键不更新
                        setattr(order, key, value)
                logger.info(f"✓ 更新订单: {tracking_no}")
            
            # ========== Step 2: 获取已存在的记录时间戳 ==========
            existing_times = {
                r.time for r in session.query(TrackingRecord.time).filter(
                    TrackingRecord.tracking_no == tracking_no
                ).all()
            }
            
            # ========== Step 3: 插入新记录(去重) ==========
            new_count = 0
            for record_data in records:
                # 时间解析
                time_str = record_data['time']
                try:
                    record_time = datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
                except ValueError:
                    logger.warning(f"时间格式错误: {time_str}")
                    continue
                
                # 去重检查
                if record_time in existing_times:
                    continue
                
                # 创建新记录
                record = TrackingRecord(
                    tracking_no=tracking_no,
                    time=record_time,
                    context=record_data['context'],
                    location=record_data.get('location', ''),
                    sequence=record_data.get('sequence', 0)
                )
                session.add(record)
                new_count += 1
            
            # ========== Step 4: 更新统计 ==========
            order.query_count += 1
            order.record_count = session.query(TrackingRecord).filter(
                TrackingRecord.tracking_no == tracking_no
            ).count()
            
            # ========== Step 5: 提交事务 ==========
            session.commit()
            
            logger.info(
                f"✓ 保存成功: {tracking_no} "
                f"(新增{new_count}条记录, 共{order.record_count}条)"
            )
            return True
            
        except Exception as e:
            session.rollback()
            logger.error(f"✗ 保存失败: {str(e)}")
            return False
            
        finally:
            session.close()
    
    def get_pending_orders(self, limit: int = 100) -> List[ExpressOrder]:
        """
        获取待查询的订单(未签收)
        
        Args:
            limit: 最大返回数量
            
        Returns:
            订单对象列表
        """
        session = self.SessionLocal()
        
        try:
            orders = session.query(ExpressOrder).filter(
                ExpressOrder.is_signed == False
            ).order_by(
                ExpressOrder.last_update.asc()  # 最久未更新的优先
            ).limit(limit).all()
            
            logger.info(f"✓ 查询到 {len(orders)} 个待追踪订单")
            return orders
            
        finally:
            session.close()
    
    def get_order_timeline(self, tracking_no: str) -> Optional[Dict]:
        """
        获取订单完整时间线
        
        Returns:
            {
                "order": {...},
                "timeline": [...]
            }
        """
        session = self.SessionLocal()
        
        try:
            order = session.query(ExpressOrder).filter(
                ExpressOrder.tracking_no == tracking_no
            ).first()
            
            if not order:
                return None
            
            records = session.query(TrackingRecord).filter(
                TrackingRecord.tracking_no == tracking_no
            ).order_by(
                TrackingRecord.time.desc()
            ).all()
            
            return {
                'order': {
                    'tracking_no': order.tracking_no,
                    'company_name': order.company_name,
                    'current_status': order.current_status,
                    'is_signed': order.is_signed,
                    'last_update': order.last_update.isoformat()
                },
                'timeline': [
                    {
                        'time': r.time.strftime('%Y-%m-%d %H:%M:%S'),
                        'context': r.context,
                        'location': r.location,
                        'sequence': r.sequence
                    }
                    for r in records
                ]
            }
            
        finally:
            session.close()

8. 核心服务层

快递追踪服务

python 复制代码
# services/tracker.py
from typing import Optional, Dict, List
from core.api_client import Kuaidi100Client
from core.data_cleaner import DataCleaner
from models.database import DatabaseManager
from utils.logger import logger

class ExpressTracker:
    """
    快递追踪核心服务
    
    整合:API调用 + 数据清洗 + 数据库存储
    """
    
    def __init__(self):
        self.api_client = Kuaidi100Client()
        self.db_manager = DatabaseManager()
        self.cleaner = DataCleaner()
    
    def track_express(self, tracking_no: str, 
                     company_code: Optional[str] = None,
                     phone_last4: Optional[str] = None) -> bool:
        """
        追踪单个快递
        
        完整流程:
        1. 自动识别快递公司(如未指定)
        2. 调用API查询
        3. 数据清洗与脱敏
        4. 保存到数据库
        5. 状态变更检测
        
        Args:
            tracking_no: 快递单号
            company_code: 快递公司代码(可选)
            phone_last4: 手机号后4位(可选)
            
        Returns:
            成功返回True
        """
        logger.info(f"\n{'='*60}")
        logger.info(f"开始追踪: {tracking_no}")
        logger.info(f"{'='*60}\n")
        
        # ========== 阶段1: 识别快递公司 ==========
        if not company_code:
            companies = self.api_client.auto_detect_company(tracking_no)
            if not companies:
                logger.error("✗ 无法识别快递公司")
                return False
            
            company_code = companies[0]['comCode']
            company_name = companies[0]['name']
        else:
            company_name = company_code  # TODO: 从配置文件映射
        
        logger.info(f"快递公司: {company_name} ({company_code})")
        
        # ========== 阶段2: 查询物流信息 ==========
        raw_data = self.api_client.query_tracking(
            company_code,
            tracking_no,
            phone_last4
        )
        
        if not raw_data:
            logger.error("✗ 查询失败")
            return False
        
        # ========== 阶段3: 数据清洗 ==========
        # 3.1 状态标准化
        status_info = self.cleaner.normalize_status(raw_data.get('state', '0'))
        
        # 3.2 构造订单数据
        order_data = {
            'tracking_no': tracking_no,
            'company_code': company_code,
            'company_name': company_name,
            'current_status': status_info['name'],
            'status_code': status_info['code'],
            'status_category': status_info['category'],
            'is_signed': raw_data.get('ischeck') == '1',
            'phone_last4': phone_last4 or '',
        }
        
        # 3.3 清洗物流记录
        raw_records = raw_data.get('data', [])
        cleaned_records = self.cleaner.clean_tracking_records(raw_records)
        
        # ========== 阶段4: 保存数据 ==========
        success = self.db_manager.save_order_with_records(
            order_data,
            cleaned_records
        )
        
        if success:
            logger.info(f"\n🎉 追踪成功: {tracking_no}")
            logger.info(f"当前状态: {status_info['name']}")
            logger.info(f"记录数量: {len(cleaned_records)}条\n")
        
        return success
    
    def batch_track(self, tracking_list: List[Dict]) -> Dict[str, int]:
        """
        批量追踪
        
        Args:
            tracking_list: [
                {"no": "SF123", "company": "shunfeng", "phone": "1234"},
                ...
            ]
            
        Returns:
            {"success": 10, "failed": 2}
        """
        stats = {'success': 0, 'failed': 0}
        
        for item in tracking_list:
            success = self.track_express(
                item['no'],
                item.get('company'),
                item.get('phone')
            )
            
            if success:
                stats['success'] += 1
            else:
                stats['failed'] += 1
        
        logger.info(f"\n批量追踪完成: 成功{stats['success']}, 失败{stats['failed']}")
        return stats

9. 定时任务调度

python 复制代码
# core/scheduler.py
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime, timedelta
from services.tracker import ExpressTracker
from models.database import DatabaseManager
from utils.logger import logger

class TrackingScheduler:
    """
    定时任务调度器
    
    功能:
    - 自动轮询未签收订单
    - 智能频率控制(根据状态调整)
    - 异常订单告警
    """
    
    def __init__(self):
        self.scheduler = BackgroundScheduler()
        self.tracker = ExpressTracker()
        self.db_manager = DatabaseManager()
        
        # 查询间隔配置(秒)
        self.intervals = {
            'delivering': 30 * 60,    # 派送中: 30分钟
            'in_transit': 2 * 3600,   # 运输中: 2小时
            'pending': 6 * 3600,      # 待揽收: 6小时
            'exception': 1 * 3600,    # 异常件: 1小时
        }
    
    def should_query_now(self, order) -> bool:
        """
        判断订单是否需要立即查询
        
        策略:
        - 已签收:不再查询
        - 其他:根据状态类别和上次更新时间判断
        """
        if order.is_signed:
            return False
        
        # 计算距离上次更新的时间
        elapsed = (datetime.now() - order.last_update).total_seconds()
        
        # 获取该状态的查询间隔
        interval = self.intervals.get(order.status_category, 3600)
        
        return elapsed >= interval
    
    def polling_task(self):
        """
        轮询任务(定时执行)
        """
        logger.info("\n========== 开始定时轮询 ==========")
        logger.info(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        
        # 获取待查询订单
        pending_orders = self.db_manager.get_pending_orders(limit=100)
        
        # 过滤需要查询的订单
        to_query = [o for o in pending_orders if self.should_query_now(o)]
        
        logger.info(f"待追踪订单: {len(pending_orders)}个")
        logger.info(f"本次查询: {len(to_query)}个\n")
        
        # 批量查询
        for idx, order in enumerate(to_query, 1):
            logger.info(f"[{idx}/{len(to_query)}] 查询: {order.tracking_no}")
            
            self.tracker.track_express(
                order.tracking_no,
                order.company_code,
                order.phone_last4
            )
            
            # 避免频控
            if idx < len(to_query):
                import time
                time.sleep(3)
        
        logger.info("========== 轮询完成 ==========\n")
    
    def start(self, interval_minutes: int = 30):
        """
        启动调度器
        
        Args:
            interval_minutes: 轮询间隔(分钟)
        """
        # 添加定时任务
        self.scheduler.add_job(
            self.polling_task,
            trigger=IntervalTrigger(minutes=interval_minutes),
            id='express_polling',
            name='快递轮询任务',
            replace_existing=True
        )
        
        # 启动调度器
        self.scheduler.start()
        
        logger.info(f"✓ 调度器已启动(间隔{interval_minutes}分钟)")
        logger.info("按Ctrl+C停止...")
    
    def stop(self):
        """停止调度器"""
        self.scheduler.shutdown()
        logger.info("✓ 调度器已停止")

10. 运行方式与结果展示

主程序入口

python 复制代码
# main.py
"""
快递物流追踪系统

使用方法:
    # 单次查询
    python main.py track SF1234567890 --phone 1234
    
    # 批量导入
    python main.py batch tracking_list.csv
    
    # 启动定时任务
    python main.py daemon --interval 30
    
    # 查看时间线
    python main.py timeline SF1234567890
"""

import argparse
import sys
from services.tracker import ExpressTracker
from core.scheduler import TrackingScheduler
from models.database import DatabaseManager
from utils.logger import logger
import json

def command_track(args):
    """
    单次追踪命令
    """
    tracker = ExpressTracker()
    
    success = tracker.track_express(
        tracking_no=args.tracking_no,
        company_code=args.company,
        phone_last4=args.phone
    )
    
    if success:
        # 显示最新状态
        db = DatabaseManager()
        timeline = db.get_order_timeline(args.tracking_no)
        
        if timeline:
            print("\n" + "="*60)
            print(f"快递单号: {timeline['order']['tracking_no']}")
            print(f"快递公司: {timeline['order']['company_name']}")
            print(f"当前状态: {timeline['order']['current_status']}")
            print(f"是否签收: {'是' if timeline['order']['is_signed'] else '否'}")
            print("="*60)
            
            print("\n最新3条物流记录:")
            for record in timeline['timeline'][:3]:
                print(f"  [{record['time']}] {record['context']}")
                if record['location']:
                    print(f"    地点: {record['location']}")
            print()

def command_batch(args):
    """
    批量追踪命令
    """
    import csv
    
    # 读取CSV文件
    tracking_list = []
    with open(args.file, 'r', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        for row in reader:
            tracking_list.append({
                'no': row['tracking_no'],
                'company': row.get('company_code', ''),
                'phone': row.get('phone_last4', '')
            })
    
    logger.info(f"从文件读取 {len(tracking_list)} 个单号")
    
    # 批量追踪
    tracker = ExpressTracker()
    stats = tracker.batch_track(tracking_list)
    
    print("\n" + "="*60)
    print(f"批量追踪完成:")
    print(f"  成功: {stats['success']}")
    print(f"  失败: {stats['failed']}")
    print("="*60 + "\n")

def command_daemon(args):
    """
    守护进程模式
    """
    scheduler = TrackingScheduler()
    
    try:
        scheduler.start(interval_minutes=args.interval)
        
        # 保持运行
        import time
        while True:
            time.sleep(1)
            
    except KeyboardInterrupt:
        logger.info("\n接收到停止信号...")
        scheduler.stop()
        sys.exit(0)

def command_timeline(args):
    """
    查看时间线
    """
    db = DatabaseManager()
    timeline = db.get_order_timeline(args.tracking_no)
    
    if not timeline:
        print(f"✗ 未找到单号: {args.tracking_no}")
        return
    
    # 以JSON格式输出
    if args.json:
        print(json.dumps(timeline, ensure_ascii=False, indent=2))
        return
    
    # 美化输出
    print("\n" + "="*60)
    print(f"快递单号: {timeline['order']['tracking_no']}")
    print(f"快递公司: {timeline['order']['company_name']}")
    print(f"当前状态: {timeline['order']['current_status']}")
    print(f"是否签收: {'✓ 是' if timeline['order']['is_signed'] else '✗ 否'}")
    print(f"最后更新: {timeline['order']['last_update']}")
    print("="*60)
    
    print(f"\n物流时间线(共{len(timeline['timeline'])}条记录):\n")
    
    for idx, record in enumerate(timeline['timeline'], 1):
        print(f"{idx}. [{record['time']}]")
        print(f"   {record['context']}")
        if record['location']:
            print(f"   📍 {record['location']}")
        print()

def main():
    parser = argparse.ArgumentParser(
        description='快递物流追踪系统',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  # 追踪顺丰快递(需要手机号后4位)
  python main.py track SF1234567890 --phone 1234
  
  # 批量导入(CSV文件格式:tracking_no,company_code,phone_last4)
  python main.py batch tracking_list.csv
  
  # 启动守护进程(每30分钟自动查询)
  python main.py daemon --interval 30
  
  # 查看完整时间线
  python main.py timeline SF1234567890
        """
    )
    
    subparsers = parser.add_subparsers(dest='command', help='子命令')
    
    # track命令
    track_parser = subparsers.add_parser('track', help='追踪单个快递')
    track_parser.add_argument('tracking_no', help='快递单号')
    track_parser.add_argument('--company', help='快递公司代码(可选)')
    track_parser.add_argument('--phone', help='手机号后4位(部分快递需要)')
    track_parser.set_defaults(func=command_track)
    
    # batch命令
    batch_parser = subparsers.add_parser('batch', help='批量追踪')
    batch_parser.add_argument('file', help='CSV文件路径')
    batch_parser.set_defaults(func=command_batch)
    
    # daemon命令
    daemon_parser = subparsers.add_parser('daemon', help='启动守护进程')
    daemon_parser.add_argument('--interval', type=int, default=30, help='轮询间隔(分钟)')
    daemon_parser.set_defaults(func=command_daemon)
    
    # timeline命令
    timeline_parser = subparsers.add_parser('timeline', help='查看时间线')
    timeline_parser.add_argument('tracking_no', help='快递单号')
    timeline_parser.add_argument('--json', action='store_true', help='JSON格式输出')
    timeline_parser.set_defaults(func=command_timeline)
    
    # 解析参数
    args = parser.parse_args()
    
    if not args.command:
        parser.print_help()
        sys.exit(1)
    
    # 执行命令
    args.func(args)

if __name__ == '__main__':
    main()

运行示例

场景1:追踪单个快递

json 复制代码
$ python main.py track SF1234567890 --phone 1234

============================================================
开始追踪: SF1234567890
============================================================

快递公司: 顺丰速运 (shunfeng)
✓ 查询成功: SF1234567890 - 5条记录
✓ 清洗完成: 5条记录(去重前5条)
✓ 新建订单: SF1234567890
✓ 保存成功: SF1234567890 (新增5条记录, 共5条)

🎉 追踪成功: SF1234567890
当前状态: 派送中
记录数量: 5条

============================================================
快递单号: SF1234567890
快递公司: 顺丰速运
当前状态: 派送中
是否签收: 否
============================================================

最新3条物流记录:
  [2026-01-29 08:30:00] 快件已到达【北京朝阳分拣中心】
    地点: 北京市
  [2026-01-28 22:15:00] 快件在【北京转运中心】已装车
    地点: 北京市
  [2026-01-28 18:00:00] 快件已发车

场景2:批量导入CSV

tracking_list.csv:

csv 复制代码
tracking_no,company_code,phone_last4
SF1234567890,shunfeng,1234
YT8888888888,yuantong,
ZTO9999999999,zhongtong,5678
bash 复制代码
$ python main.py batch tracking_list.csv

从文件读取 3 个单号
正在查询 1/3: SF1234567890
✓ 追踪成功
正在查询 2/3: YT8888888888
✓ 追踪成功
正在查询 3/3: ZTO9999999999
✓ 追踪成功

============================================================
批量追踪完成:
  成功: 3
  失败: 0
============================================================

场景3:守护进程模式

bash 复制代码
$ python main.py daemon --interval 30

✓ 调度器已启动(间隔30分钟)
按Ctrl+C停止...

========== 开始定时轮询 ==========
时间: 2026-01-29 10:00:00
待追踪订单: 15个
本次查询: 8个

[1/8] 查询: SF1234567890
✓ 更新订单: SF1234567890
...
========== 轮询完成 ==========

场景4:查看完整时间线

bash 复制代码
$ python main.py timeline SF1234567890

============================================================
快递单号: SF1234567890
快递公司: 顺丰速运
当前状态: 已签收
是否签收: ✓ 是
最后更新: 2026-01-29T14:30:00
============================================================

物流时间线(共7条记录):

1. [2026-01-29 14:30:00]
   您的快递已签收,签收人:本人签收
   📍 北京市

2. [2026-01-29 08:30:00]
   快件已到达【北京朝阳分拣中心】
   📍 北京市

3. [2026-01-28 22:15:00]
   快件在【北京转运中心】已装车
   📍 北京市
...

数据库查询示例

sql 复制代码
-- 1. 统计各快递公司订单量
SELECT company_name, COUNT(*) as count
FROM express_orders
GROUP BY company_name
ORDER BY count DESC;

-- 结果:
-- 顺丰速运  45
-- 圆通速递  32
-- 中通快递  28

-- 2. 查找超过3天未签收的订单
SELECT tracking_no, company_name, current_status, 
       ROUND((JULIANDAY('now') - JULIANDAY(created_at))) as days
FROM express_orders
WHERE is_signed = 0 
  AND JULIANDAY('now') - JULIANDAY(created_at) > 3
ORDER BY days DESC;

-- 3. 统计平均物流节点数
SELECT AVG(record_count) as avg_records
FROM express_orders
WHERE is_signed = 1;

-- 结果:avg_records = 8.5

-- 4. 查询某个订单的完整轨迹
SELECT time, context, location
FROM tracking_records
WHERE tracking_no = 'SF1234567890'
ORDER BY time DESC;

11. 常见问题与排错

问题1:API返回501错误(需要手机号)

错误信息:

json 复制代码
{"returnCode": "501", "message": "此快递公司需要输入手机号后四位"}

原因: 顺丰、丰网等快递要求手机号验证

解决方案:

python 复制代码
# 方法1:手动指定
tracker.track_express('SF123', phone_last4='1234')

# 方法2:从数据库读取
order = db.get_order('SF123')
if order.need_phone:
    phone = input(f"请输入{order.tracking_no}的手机号后4位: ")
    tracker.track_express(order.tracking_no, phone_last4=phone)

问题2:签名验证失败

错误信息:

json 复制代码
{"returnCode": "500", "message": "验签失败"}

排查步骤:

python 复制代码
# 1. 检查环境变量
from decouple import config
print(f"CUSTOMER: {config('KUAIDI100_CUSTOMER')}")
print(f"KEY: {config('KUAIDI100_KEY')}")

# 2. 打印签名过程
param = '{"com":"shunfeng","num":"SF123"}'
print(f"参数: {param}")

sign_str = param + key + customer
print(f"签名原文: {sign_str}")

sign = hashlib.md5(sign_str.encode()).hexdigest().upper()
print(f"签名结果: {sign}")

# 3. 对比官方示例
# https://www.kuaidi100.com/openapi/applycode.shtml

问题3:查询频率过高被限流

现象: 返回空数据或429错误

解决方案:

python 复制代码
# 配置文件增加频控
RATE_LIMIT = {
    'min_interval': 3,  # 最小间隔3秒
    'max_per_hour': 100,  # 每小时最多100次
}

# 使用装饰器
from functools import wraps
import time

def rate_limit(min_interval=3):
    last_call = [0]
    
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            elapsed = time.time() - last_call[0]
            if elapsed < min_interval:
                time.sleep(min_interval - elapsed)
            
            result = func(*args, **kwargs)
            last_call[0] = time.time()
            return result
        return wrapper
    return decorator

@rate_limit(min_interval=3)
def query_tracking(...):
    ...

问题4:时间格式不统一

现象: 部分快递返回时间格式为"01/29 08:30"

处理方法:

python 复制代码
from dateutil import parser
from datetime import datetime

def normalize_time(time_str: str) -> datetime:
    """
    智能解析时间
    
    支持格式:
    - 2026-01-29 08:30:00
    - 01-29 08:30
    - 2026/01/29 08:30
    """
    try:
        # 尝试标准格式
        return datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
    except ValueError:
        pass
    
    try:
        # 使用dateutil智能解析
        dt = parser.parse(time_str)
        
        # 如果缺少年份,补充当前年份
        if dt.year == 1900:
            dt = dt.replace(year=datetime.now().year)
        
        return dt
    except:
        logger.warning(f"无法解析时间: {time_str}")
        return datetime.now()

问题5:数据库锁定

错误信息:

json 复制代码
sqlite3.OperationalError: database is locked

解决方案:

python 复制代码
# 1. 增加超时时间
engine = create_engine(
    'sqlite:///express.db',
    connect_args={'timeout': 30}  # 默认5秒
)

# 2. 启用WAL模式
import sqlite3
conn = sqlite3.connect('express.db')
conn.execute('PRAGMA journal_mode=WAL')
conn.close()

# 3. 改用PostgreSQL(生产环境推荐)
engine = create_engine('postgresql://user:pass@localhost/express')

12. 进阶优化

12.1 异常订单告警

python 复制代码
# services/notifier.py
import requests
from utils.logger import logger

class DingTalkNotifier:
    """
    钉钉群机器人通知
    """
    
    def __init__(self, webhook_url: str):
        self.webhook_url = webhook_url
    
    def send_alert(self, title: str, content: str):
        """
        发送告警消息
        """
        data = {
            "msgtype": "markdown",
            "markdown": {
                "title": title,
                "text": f"### {title}\n\n{content}"
            }
        }
        
        try:
            response = requests.post(self.webhook_url, json=data)
            if response.status_code == 200:
                logger.info("✓ 告警发送成功")
        except Exception as e:
            logger.error(f"✗ 告警发送失败: {str(e)}")

# 使用示例
notifier = DingTalkNotifier('https://oapi测异常订单
if order.status_category == 'exception':
    notifier.send_alert(
        title="快递异常告警",
        content=f"""
        - 快递单号: {order.tracking_no}
        - 快递公司: {order.company_name}
        - 当前状态: {order.current_status}
        - 创建时间: {order.created_at}
        """
    )

12.2 可视化时间线

python 复制代码
# utils/visualizer.py
import matplotlib.pyplot as plt
from matplotlib import font_manager
from datetime import datetime

class TimelineVisualizer:
    """
    物流时间线可视化
    """
    
    def __init__(self):
        # 设置中文字体
        plt.rcParams['font.sans-serif'] = ['SimHei']
        plt.rcParams['axes.unicode_minus'] = False
    
    def plot_timeline(self, timeline_data: dict, output_path: str):
        """
        绘制时间线图表
        """
        records = timeline_data['timeline']
        
        # 提取数据
        times = [datetime.strptime(r['time'], '%Y-%m-%d %H:%M:%S') for r in records]
        contexts = [r['context'][:20] + '...' for r in records]  # 截断文字
        
        # 创建图表
        fig, ax = plt.subplots(figsize=(12, 8))
        
        # 绘制时间线
        ax.plot(times, range(len(times)), 'o-', linewidth=2, markersize=10)
        
        # 添加标签
        for i, (time, context) in enumerate(zip(times, contexts)):
            ax.text(time, i, f'  {context}', va='center', fontsize=9)
        
        # 设置标题和标签
        ax.set_title(f"物流时间线 - {timeline_data['order']['tracking_no']}", fontsize=14)
        ax.set_xlabel('时间', fontsize=12)
        ax.set_ylabel('节点序号', fontsize=12)
        
        # 旋转x轴标签
        plt.xticks(rotation=45)
        
        plt.tight_layout()
        plt.savefig(output_path, dpi=300)
        plt.close()
        
        logger.info(f"✓ 时间线图表已保存: {output_path}")

12.3 数据分析示例

python 复制代码
# analytics/express_analyzer.py
import pandas as pd
from models.database import DatabaseManager

class ExpressAnalyzer:
    """
    快递数据分析
    """
    
    def __init__(self):
        self.db = DatabaseManager()
    
    def analyze_delivery_time(self):
        """
        分析平均送达时间
        """
        # 导出到Pandas
        import sqlite3
        conn = sqlite3.connect('./data/express.db')
        
        df = pd.read_sql("""
            SELECT 
                company_name,
                ROUND(AVG(JULIANDAY(signed_at) - JULIANDAY(created_at)), 1) as avg_days
            FROM express_orders
            WHERE is_signed = 1 AND signed_at IS NOT NULL
            GROUP BY company_name
            ORDER BY avg_days
        """, conn)
        
        print("\n各快递公司平均送达时间(天):")
        print(df.to_string(index=False))
        
        return df
    
    def find_problem_routes(self):
        """
        识别问题线路(经常延误的城市)
        """
        conn = sqlite3.connect('./data/express.db')
        
        df = pd.read_sql("""
            SELECT 
                location,
                COUNT(*) as count,
                AVG(JULIANDAY('now') - JULIANDAY(time)) as avg_stay_days
            FROM tracking_records
            WHERE location != ''
            GROUP BY location
            HAVING count > 5 AND avg_stay_days > 2
            ORDER BY avg_stay_days DESC
        """, conn)
        
        print("\n可能存在延误的地点:")
        print(df.head(10).to_string(index=False))
        
        return df

13. 总结与延伸

我们完成了什么?

构建了合规的物流追踪系统 :严格遵守隐私保护要求

实现了智能频控策略 :根据状态动态调整查询间隔

设计了完整的数据模型 :订单表+节点表+时间线

开发了自动化工具:定时轮询、批量导入、异常告警

实战经验总结

这个项目让我深刻理解了合规性在爬虫开发中的重要性。一开始我想做一个"全网快递监控"功能,但研究法律后发现,未经授权查询他人快递属于侵犯隐私。最终把范围限制在"用户主动提交的单号",既合法又实用。

踩过的坑:

  • 忘记脱敏电话号码,差点泄露隐私
  • 查询频率设置为每分钟一次,第二天IP被封
  • SQLite并发写入导致数据库损坏

下一步可以做什么?

  1. Web界面开发

    • 用Flask/Django开发管理后台
    • 支持可视化时间线展示
    • 提供微信/企业微信通知
  2. 多平台支持

    • 接入菜鸟裹裹、京东物流API
    • 支持国际快递(DHL、FedEx)
  3. 智能预测

    • 基于历史数据预测送达时间
    • 异常件自动识别(超时、滞留)
  4. 隐私增强

    • 实现端到端加密存储
    • 支持数据自动过期删除

延伸阅读

  • 《个人信息保护法》:了解法律边界
  • 快递100开放平台文档https://www.kuaidi100.com/openapi/
  • APScheduler官方教程:掌握定时任务
  • SQLAlchemy ORM指南:精通数据库操作

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
2301_790300967 小时前
Python单元测试(unittest)实战指南
jvm·数据库·python
Data_Journal7 小时前
Scrapy vs. Crawlee —— 哪个更好?!
运维·人工智能·爬虫·媒体·社媒营销
VCR__7 小时前
python第三次作业
开发语言·python
韩立学长7 小时前
【开题答辩实录分享】以《助农信息发布系统设计与实现》为例进行选题答辩实录分享
python·web
深蓝电商API7 小时前
async/await与多进程结合的混合爬虫架构
爬虫·架构
2401_838472517 小时前
使用Scikit-learn构建你的第一个机器学习模型
jvm·数据库·python
u0109272717 小时前
使用Python进行网络设备自动配置
jvm·数据库·python
工程师老罗7 小时前
优化器、反向传播、损失函数之间是什么关系,Pytorch中如何使用和设置?
人工智能·pytorch·python
Fleshy数模7 小时前
我的第一只Python爬虫:从Requests库到爬取整站新书
开发语言·爬虫·python
CoLiuRs7 小时前
Image-to-3D — 让 2D 图片跃然立体*
python·3d·flask