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

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 摘要(Abstract)
- [1. 背景与需求(Why)](#1. 背景与需求(Why))
- [2. 合规与注意事项(必读重点)](#2. 合规与注意事项(必读重点))
- [3. 技术选型与整体流程(What/How)](#3. 技术选型与整体流程(What/How))
- [4. 环境准备与依赖安装](#4. 环境准备与依赖安装)
- [5. 核心实现:API客户端层](#5. 核心实现:API客户端层)
-
- [快递100 API分析](#快递100 API分析)
- API客户端完整实现
- 缓存管理器
- [6. 核心实现:数据清洗与脱敏层](#6. 核心实现:数据清洗与脱敏层)
- [7. 数据存储层(Database)](#7. 数据存储层(Database))
- [8. 核心服务层](#8. 核心服务层)
- [9. 定时任务调度](#9. 定时任务调度)
- [10. 运行方式与结果展示](#10. 运行方式与结果展示)
- [11. 常见问题与排错](#11. 常见问题与排错)
- [12. 进阶优化](#12. 进阶优化)
-
- [12.1 异常订单告警](#12.1 异常订单告警)
- [12.2 可视化时间线](#12.2 可视化时间线)
- [12.3 数据分析示例](#12.3 数据分析示例)
- [13. 总结与延伸](#13. 总结与延伸)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: 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签名¶m={"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并发写入导致数据库损坏
下一步可以做什么?
-
Web界面开发:
- 用Flask/Django开发管理后台
- 支持可视化时间线展示
- 提供微信/企业微信通知
-
多平台支持:
- 接入菜鸟裹裹、京东物流API
- 支持国际快递(DHL、FedEx)
-
智能预测:
- 基于历史数据预测送达时间
- 异常件自动识别(超时、滞留)
-
隐私增强:
- 实现端到端加密存储
- 支持数据自动过期删除
延伸阅读
- 《个人信息保护法》:了解法律边界
- 快递100开放平台文档 :https://www.kuaidi100.com/openapi/
- APScheduler官方教程:掌握定时任务
- SQLAlchemy ORM指南:精通数据库操作
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

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

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