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

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
- [3️⃣ 合规与注意事项(必写)](#3️⃣ 合规与注意事项(必写))
- [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
- [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
- [6️⃣ 核心实现:请求层(Fetcher)](#6️⃣ 核心实现:请求层(Fetcher))
- [7️⃣ 核心实现:数据存储层(Storage)](#7️⃣ 核心实现:数据存储层(Storage))
- [8️⃣ 核心实现:调度层(Scheduler)](#8️⃣ 核心实现:调度层(Scheduler))
- [9️⃣ 核心实现:告警层(Alert)](#9️⃣ 核心实现:告警层(Alert))
- [🔟 核心实现:分析层(Analyzer)](#🔟 核心实现:分析层(Analyzer))
- [1️⃣1️⃣ 运行方式与结果展示](#1️⃣1️⃣ 运行方式与结果展示)
- [1️⃣2️⃣ 常见问题与排错](#1️⃣2️⃣ 常见问题与排错)
-
- [Q1: 定时任务不执行?](#Q1: 定时任务不执行?)
- [Q2: 数据库锁定(database is locked)?](#Q2: 数据库锁定(database is locked)?)
- [Q3: 邮件发送失败(535 Login Fail)?](#Q3: 邮件发送失败(535 Login Fail)?)
- [Q4: 爬取频率过高被封IP?](#Q4: 爬取频率过高被封IP?)
- [Q5: 价格趋势图中文显示乱码?](#Q5: 价格趋势图中文显示乱码?)
- [Q6: 系统重启后任务丢失?](#Q6: 系统重启后任务丢失?)
- [1️⃣3️⃣ 进阶优化](#1️⃣3️⃣ 进阶优化)
-
- [1. 多商品批量监控优化](#1. 多商品批量监控优化)
- [2. 智能告警策略](#2. 智能告警策略)
- [3. 数据备份与恢复](#3. 数据备份与恢复)
- [4. Web可视化界面(Flask)](#4. Web可视化界面(Flask))
- [1️⃣4️⃣ 总结与延伸阅读](#1️⃣4️⃣ 总结与延伸阅读)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
1️⃣ 摘要(Abstract)
目标:构建一套自动化的电商价格监控系统,使用Python爬虫定时抓取指定商品(如iPhone 15 Pro)的价格数据,通过APScheduler实现每日/每小时自动采集,将历史数据存储到SQLite数据库,并支持CSV导出、价格趋势可视化、降价告警等功能。
你将获得:
- 掌握定时任务框架APScheduler的实战应用,实现灵活的Cron表达式调度
- 学会构建完整的时序数据存储方案,处理价格历史记录的增删改查
- 理解价格监控业务逻辑:阈值告警、环比计算、异常检测等核心算法
- 获得一套可直接部署的监控系统代码,配置后即可用于任何电商平台
2️⃣ 背景与需求(Why)
为什么要做价格监控?
作为一个长期关注数码产品的消费者,我深刻体会到电商价格的波动性有多大。同一款iPhone,双11可能便宜500元,平时可能突然涨价200元。更让人头疼的是,这些价格变化往往发生在你没注意的时候------等你想买时,促销已经结束了。
我曾经因为犹豫了两天,错过了一款机械键盘的史低价(399元),再次看到时已经涨回599元。那一刻我意识到:手动刷新价格是低效的,我需要一个自动化的监控系统。
更进一步地,价格监控不仅仅是为了省钱,它还有更深层的价值:
- 商业分析:研究竞品的定价策略、促销节奏
- 市场预测:通过历史价格趋势预判未来走向
- 消费决策:基于数据而非冲动购买
- 套利机会:发现跨平台价差(京东vs天猫)
目标场景与需求清单
核心场景:监控京东上"iPhone 15 Pro 256GB 黑色钛金属"这一具体商品
功能需求:
| 功能模块 | 具体需求 | 技术实现 |
|---|---|---|
| 定时采集 | 每天早8点、晚8点自动爬取 | APScheduler Cron |
| 数据存储 | 记录价格、库存、促销标签等历史数据 | SQLite时序表 |
| 降价告警 | 价格低于设定阈值时发送通知 | 邮件/企业微信 |
| 趋势分析 | 生成最近30天的价格曲线图 | Matplotlib |
| 数据导出 | 支持CSV导出供Excel分析 | Pandas |
| 异常处理 | 爬取失败时重试+日志记录 | Logging + Retry |
数据字段设计:
| 字段名 | 类型 | 示例值 | 说明 |
|---|---|---|---|
| product_id | VARCHAR(50) | "100012345678" | 商品SKU(主键之一) |
| product_name | TEXT | "Apple iPhone 15 Pro..." | 商品全称 |
| price | DECIMAL(10,2) | 7999.00 | 当前价格 |
| original_price | DECIMAL(10,2) | 8999.00 | 原价/划线价 |
| stock_status | VARCHAR(20) | "有货" / "无货" | 库存状态 |
| promotion_tag | TEXT | "满减100" | 促销信息 |
| platform | VARCHAR(20) | "JD" | 平台标识 |
| captured_at | TIMESTAMP | "2024-01-28 20:00:00" | 采集时间(主键之一) |
| data_source | VARCHAR(50) | "API" / "HTML" | 数据来源 |
3️⃣ 合规与注意事项(必写)
价格监控的法律边界
价格监控比普通爬虫更敏感,因为它涉及商业竞争和定价策略。在开发前,必须明确几个原则:
✅ 可以做的:
- 监控公开展示的商品价格(无需登录即可查看)
- 为个人消费决策提供参考
- 学术研究和市场分析(非商业用途)
❌ 禁止做的:
- 批量监控竞争对手全品类价格并用于恶意竞价
- 将价格数据打包出售给第三方
- 利用监控数据操纵市场价格
- 高频爬取导致平台服务器压力
频率控制的黄金法则
根据我的实测经验,价格监控的频率应该遵循"按需适度"原则:
不同场景的推荐频率:
| 监控场景 | 推荐频率 | 原因 |
|---|---|---|
| 日常监控 | 每天2次(早8点、晚8点) | 覆盖主要促销时段 |
| 大促期间 | 每2小时1次 | 618/双11价格波动频繁 |
| 热门新品 | 每小时1次 | 上市初期价格调整快 |
| 长期追踪 | 每周1次 | 观察长期趋势即可 |
技术实现建议:
python
# 好的做法:使用随机延迟
import random
time.sleep(random.uniform(5, 15)) # 5-15秒随机延迟
# 坏的做法:固定高频
for i in range(1000):
fetch_price() # 无延迟批量请求 ❌
数据存储的隐私保护
虽然价格数据是公开的,但在存储时仍需注意:
- 不记录用户评论中的个人信息(昵称、头像)
- 不存储需要登录才能看到的会员价
- 定期清理超过1年的历史数据(除非有特殊需求)
4️⃣ 技术选型与整体流程(What/How)
为什么选择APScheduler?
在Python定时任务领域,有多种选择:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| cron | 系统级,稳定 | 需要配置服务器 | Linux生产环境 |
| Celery | 分布式,功能强 | 依赖Redis/RabbitMQ | 大型系统 |
| APScheduler | 轻量,易用 | 单机,无法分布式 | 中小型项目 ✅ |
| schedule | 极简 | 功能有限 | 快速原型 |
最终选择APScheduler的原因:
- 开箱即用:无需额外安装消息队列
- 表达能力强:支持Cron、Interval、Date多种触发器
- 持久化支持:可将任务存储到数据库,重启后恢复
- 灵活性高:可以在运行时动态添加/删除任务
技术栈全景图
json
┌─────────────────────────────────────────┐
│ 应用层(Application) │
│ ┌──────────────────────────────────┐ │
│ │ main.py - 主入口 │ │
│ │ monitor.py - 监控逻辑 │ │
│ │ alert.py - 告警模块 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 调度层(Scheduler) │
│ ┌──────────────────────────────────┐ │
│ │ APScheduler 3.10 │ │
│ │ - CronTrigger(定时) │ │
│ │ - IntervalTrigger(间隔) │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 爬虫层(Scraper) │
│ ┌──────────────────────────────────┐ │
│ │ requests 2.31 - HTTP请求 │ │
│ │ lxml 5.1 - HTML解析 │ │
│ │ fake-useragent - UA轮换 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 存储层(Storage) │
│ ┌──────────────────────────────────┐ │
│ │ SQLite3 - 时序数据库 │ │
│ │ Pandas - CSV导出 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 分析层(Analysis) │
│ ┌──────────────────────────────────┐ │
│ │ Matplotlib - 价格趋势图 │ │
│ │ NumPy - 统计计算 ────┘ │
└─────────────────────────────────────────┘
核心流程设计
json
┌──────────────┐
│ APScheduler │
│ 定时触发器 │
└──────┬───────┘
│
▼
┌──────────────┐
│ 执行监控任务 │ (monitor_job)
└──────┬───────┘
│
├─→ ┌─────────────┐
│ │ 1. 读取配置 │ (监控商品列表)
│ └─────┬───────┘
│ │
│ ┌─────▼───────┐
│ │ 2. 遍历商品 │ (for product in list)
│ └─────┬───────┘
│ │
│ ┌─────▼───────┐
│ │ 3. 爬取数据 │ (fetch_price)
│ └─────┬───────┘
│ │
│ ┌─────▼───────┐
│ │ 4. 解析价格 │ (parse_product_info)
│ └─────┬───────┘
│ │
│ ┌─────▼───────┐
│ │ 5. 数据验证 │ (validate_data)
│ └─────┬───────┘
│ │
│ ┌─────▼───────┐
│ │ 6. 存入数据库│ (insert_price_history)
│ └─────┬───────┘
│ │
│ ┌─────▼───────┐
│ │ 7. 检查告警 │ (check_alert_threshold)
│ └─────┬───────┘
│ │
│ ├─ 价格降低?─→ ┌─────────────┐
│ │ │ 发送邮件通知 │
│ │ └─────────────┘
│ │
│ ├─ 无货变有货?─→ ┌─────────────┐
│ │ │ 发送补货提醒 │
│ │ └─────────────┘
│ │
│ └─ 异常波动?──→ ┌─────────────┐
│ │ 记录异常日志 │
│ └─────────────┘
│
└─→ ┌─────────────┐
│ 8. 生成报告 │ (每天一次)
└─────┬───────┘
│
┌─────▼───────┐
│ 导出CSV │
└─────┬───────┘
│
┌─────▼───────┐
│ 绘制趋势图 │
└─────────────┘
5️⃣ 环境准备与依赖安装(可复现)
Python版本要求
bash
Python 3.9+ # 推荐3.11(性能更优)
完整依赖清单
json
# 创建虚拟环境
python -m venv price_monitor_env
source price_monitor_env/bin/activate # Windows: price_monitor_env\Scripts\activate
# 核心依赖
pip install apscheduler==3.10.4 # 定时任务框架
pip install requests==2.31.0 # HTTP请求
pip install lxml==5.1.0 # HTML解析
pip install fake-useragent==1.4.0 # UA生成
# 数据处理
pip install pandas==2.1.4 # CSV导出
pip install numpy==1.26.2 # 数值计算
# 可视化
pip install matplotlib==3.8.2 # 绘图
pip install seaborn==0.13.0 # 美化图表
# 告警通知(可选)
pip install yagmail==0.15.293 # 邮件发送
pip install requests-toolbelt==1.0.0 # 企业微信机器人
项目目录结构
json
price_monitor/
│
├── config.py # 配置文件(商品列表、告警阈值)
├── scraper.py # 爬虫模块(京东/淘宝)
├── storage.py # 数据库操作
├── scheduler.py # APScheduler调度器
├── alert.py # 告警模块(邮件/微信)
├── analyzer.py # 数据分析(趋势图/统计)
├── main.py # 主入口
├── requirements.txt # 依赖清单
│
├── data/
│ ├── price_history.db # SQLite数据库
│ ├── exports/ # CSV导出目录
│ │ └── price_20240128.csv
│ └── charts/ # 图表保存目录
│ └── trend_iphone15.png
│
├── logs/
│ ├── monitor.log # 监控日志
│ └── alert.log # 告警日志
│
└── tests/
└── test_scraper.py # 单元测试
创建目录:
json
mkdir -p price_monitor/{data/exports,data/charts,logs,tests}
cd price_monitor
6️⃣ 核心实现:请求层(Fetcher)
配置文件(格监控系统配置文件
所有可调参数集中在此,方便维护
json
import os
from datetime import datetime
# ========== 基础路径配置 ==========
BASE_DIR = os.path.dirname(os.path.abspath(**file**))
DATA_DIR = os.path.join(BASE_DIR, 'data')
LOG_DIR = os.path.join(BASE_DIR, 'logs')
EXPORT_DIR = os.path.join(DATA_DIR, 'exports')
CHART_DIR = os.path.join(DATA_DIR, 'charts')
# 确保目录存在
for directory in [DATA_DIR, LOG_DIR, EXPORT_DIR, CHART_DIR]:
os.makedirs(directory, exist_ok=True)
# ========== 数据库配置 ==========
DATABASE_PATH = os.path.join(DATA_DIR, 'price_history.db')
CSV_EXPORT_PATH = os.path.join(EXPORT_DIR, f'price_{datetime.now().strftime("%Y%m%d")}.csv')
# ========== 监控商品配置 ==========
# 支持监控多个商品,每个商品包含:
# - product_id: 商品SKU
# - product_name: 商品名称(用于日志和通知)
# - url: 商品详情页URL
# - alert_threshold: 告警阈值(低于此价格时发送通知)
MONITORED_PRODUCTS = [
{
'product_id': '100012345678',
'product_name': 'Apple iPhone 15 Pro 256GB 黑色钛金属',
'url': '[https://item.jd.com/100012345678.html](https://item.jd.com/100012345678.html)',
'platform': 'JD',
'alert_threshold': 7500.00, # 低于7500元告警
'enabled': True # 是否启用监控
},
{
'product_id': '100087654321',
'product_name': 'Cherry MX8.0机械键盘 青轴',
'url': '[https://item.jd.com/100087654321.html](https://item.jd.com/100087654321.html)',
'platform': 'JD',
'alert_threshold': 399.00,
'enabled': True
},
# 可以继续添加更多商品...
]
# ========== 定时任务配置 ==========
# APScheduler支持三种触发器:
# 1. CronTrigger:类似Linux cron,使用表达式定义时间
# 2. IntervalTrigger:固定间隔执行
# 3. DateTrigger:一次性任务
SCHEDULE_CONFIG = {
# 主监控任务:每天早8点和晚8点执行
'main_monitor': {
'trigger': 'cron',
'hour': '8,20', # 8点和20点
'minute': '0',
'second': '0'
},
# 大促期间可切换为高频监控
'promo_monitor': {
'trigger': 'interval',
'hours': 2, # 每2小时一次
'enabled': False # 默认禁用,大促期间手动开启
},
# 每日报告生成:凌晨1点
'daily_report': {
'trigger': 'cron',
'hour': '1',
'minute': '0'
},
# 每周趋势分析:周日晚上10点
'weekly_analysis': {
'trigger': 'cron',
'day_of_week': 'sun',
'hour': '22',
'minute': '0'
}
}
# ========== 爬虫配置 ==========
# 请求超时设置(秒)
TIMEOUT = 15
# 失败重试次数
RETRY_TIMES = 3
# 请求延迟范围(秒):随机延迟避免被识别
DELAY_RANGE = (3, 8)
# User-Agent池(轮换使用)
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0'
]
# 京东价格API配置
JD_PRICE_API = '[https://p.3.cn/prices/mgets](https://p.3.cn/prices/mgets)'
# ========== 告警配置 ==========
# 告警方式:email, wechat, both
ALERT_METHOD = 'email'
# 邮件配置(使用QQ邮箱示例)
EMAIL_CONFIG = {
'enabled': True,
'smtp_server': 'smtp.qq.com',
'smtp_port': 465,
'sender': '[your_email@qq.com](mailto:your_email@qq.com)', # 发件人
'password': 'your_smtp_password', # SMTP授权码(非QQ密码)
'receivers': ['[notify_email@example.com](mailto:notify_email@example.com)'], # 收件人列表
'ssl': True
}
# 企业微信机器人配置
WECHAT_CONFIG = {
'enabled': False,
'webhook_url': '[https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY](https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY)'
}
# ========== 数据分析配置 ==========
# 价格异常检测:日涨跌幅超过此值视为异常
PRICE_CHANGE_THRESHOLD = 0.15 # 15%
# 趋势图配置
CHART_CONFIG = {
'figure_size': (12, 6),
'dpi': 100,
'style': 'seaborn-v0_8', # matplotlib样式
'show_grid': True
}
# ========== 日志配置 ==========
LOGGING_CONFIG = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s - [%(levelname)s] - %(name)s - %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S'
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'standard'
},
'file': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOG_DIR, 'monitor.log'),
'maxBytes': 10485760, # 10MB
'backupCount': 5,
'formatter': 'standard',
'encoding': 'utf-8'
},
},
'loggers': {
'': { # root logger
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': False
}
}
}
爬虫模块(scraper.py)
python
"""
爬虫模块:负责从电商平台抓取商品价格数据
支持京东、淘宝等多平台(本示例以京东为主)
"""
import requests
import time
import random
import logging
from lxml import etree
from typing import Dict, Optional
from fake_useragent import UserAgent
from config import *
# 配置日志
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger(__name__)
class PriceScraper:
"""
价格爬虫基类
提供通用的请求、重试、延迟等功能
"""
def __init__(self):
"""
初始化爬虫
- 创建Session对象复用连接
- 设置基础headers
"""
self.session = requests.Session()
self.ua = UserAgent()
self._setup_session()
def _setup_session(self):
"""配置Session的默认headers"""
self.session.headers.update({
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1'
})
def _get_random_ua(self) -> str:
"""
获取随机User-Agent
优先使用配置文件中的UA池,失败则用fake-useragent生成
"""
try:
return random.choice(USER_AGENTS)
except:
return self.ua.random
def _random_delay(self):
"""
执行随机延迟
避免请求过快被识别为爬虫
"""
delay = random.uniform(*DELAY_RANGE)
logger.debug(f"随机延迟 {delay:.2f} 秒")
time.sleep(delay)
def _fetch_with_retry(self, url: str, retries: int = RETRY_TIMES) -> Optional[str]:
"""
带重试机制的HTTP请求
Args:
url: 目标URL
retries: 重试次数
Returns:
HTML文本 或 None(失败时)
实现逻辑:
1. 每次请求前更换User-Agent
2. 遇到429/503时使用指数退避
3. 超时、连接错误时重试
4. 记录详细日志便于排查问题
"""
for attempt in range(1, retries + 1):
try:
# 更换User-Agent
self.session.headers['User-Agent'] = self._get_random_ua()
logger.debug(f"第 {attempt}/{retries} 次请求: {url}")
response = self.session.get(
url,
timeout=TIMEOUT,
allow_redirects=True
)
# 检查响应状态
if response.status_code == 200:
# 验证响应内容是否有效
if len(response.text) < 500:
logger.warning(f"响应内容过短({len(response.text)}字节),可能被反爬")
time.sleep(10)
continue
logger.info(f"✓ 成功获取: {url}")
self._random_delay() # 成功后也要延迟
return response.text
elif response.status_code == 404:
logger.error(f"404 商品不存在: {url}")
return None # 404不重试
elif response.status_code == 429:
# 被限流,使用指数退避
wait_time = 2 ** attempt * 5 # 10s, 20s, 40s
logger.warning(f"429 被限流,等待 {wait_time} 秒")
time.sleep(wait_time)
elif response.status_code in [302, 301]:
logger.warning(f"重定向到: {response.headers.get('Location')}")
else:
logger.warning(f"状态码异常: {response.status_code}")
except requests.Timeout:
logger.error(f"请求超时 (尝试 {attempt}/{retries})")
time.sleep(5)
except requests.ConnectionError as e:
logger.error(f"连接错误: {e}")
time.sleep(10)
except Exception as e:
logger.error(f"未知错误: {e}", exc_info=True)
# 所有重试失败
logger.error(f"✗ {retries}次尝试后仍失败: {url}")
return None
def fetch_product_info(self, product_config: Dict) -> Optional[Dict]:
"""
获取商品完整信息(需子类实现具体解析逻辑)
Args:
product_config: 商品配置字典(来自config.py)
Returns:
商品信息字典,包含price、stock_status等字段
"""
raise NotImplementedError("子类必须实现此方法")
class JDPriceScraper(PriceScraper):
"""
京东价格爬虫
技术要点:
1. 商品详情页是SSR,HTML中包含基本信息
2. 价格通过异步API获取(https://p.3.cn/prices/mgets)
3. 库存状态在HTML中,但需要处理多种文案
"""
def fetch_product_info(self, product_config: Dict) -> Optional[Dict]:
"""
获取京东商品信息
流程:
1. 请求商品详情页HTML
2. 解析商品名称、促销标签等
3. 调用价格API获取实时价格
4. 组装完整数据返回
"""
url = product_config['url']
product_id = product_config['product_id']
logger.info(f"开始监控商品: {product_config['product_name']}")
# 1. 获取详情页HTML
html = self._fetch_with_retry(url)
if not html:
return None
# 2. 解析HTML
tree = etree.HTML(html)
try:
# 商品标题(用于验证)
title = tree.xpath('//div[@class="sku-name"]/text()')
title = title[0].strip() if title else product_config['product_name']
# 库存状态
# 京东的库存文案:有货、无货、预约、到货通知
stock_elem = tree.xpath('//div[@id="store-prompt"]//text()')
stock_text = ''.join(stock_elem).strip()
stock_status = self._parse_stock_status(stock_text)
# 促销标签
promo = tree.xpath('//div[@class="promo-words"]//text()')
promo_tag = ' | '.join([p.strip() for p in promo if p.strip()])
logger.debug(f"解析到商品标题: {title}")
logger.debug(f"库存状态: {stock_status}")
logger.debug(f"促销信息: {promo_tag if promo_tag else '无'}")
except Exception as e:
logger.error(f"HTML解析失败: {e}")
title = product_config['product_name']
stock_status = "未知"
promo_tag = ""
# 3. 获取价格(调用API)
price, original_price = self._fetch_jd_price(product_id)
if price is None:
logger.warning("价格获取失败,跳过本次监控")
return None
# 4. 组装完整数据
product_info = {
'product_id': product_id,
'product_name': title,
'price': price,
'original_price': original_price,
'stock_status': stock_status,
'promotion_tag': promo_tag,
'platform': 'JD',
'data_source': 'API',
'url': url
}
logger.info(f"✓ 商品信息获取成功: 价格={price}元, 库存={stock_status}")
return product_info
def _fetch_jd_price(self, sku_id: str) -> tuple:
"""
调用京东价格API
API说明:
- URL: https://p.3.cn/prices/mgets
- 参数: skuIds=J_{sku_id}&type=1
- 返回: [{"id":"J_xxx","p":"999.00","op":"1299.00"}]
Returns:
(当前价格, 原价) 元组
"""
params = {
'skuIds': f'J_{sku_id}',
'type': 1
}
try:
response = self.session.get(
JD_PRICE_API,
params=params,
timeout=10
)
if response.status_code == 200:
data = response.json()
if data and len(data) > 0:
item = data[0]
price = float))
logger.debug(f"价格API返回: 现价={price}, 原价={original_price}")
return price, original_price
except Exception as e:
logger.error(f"价格API请求失败: {e}")
return None, None
def _parse_stock_status(self, text: str) -> str:
"""
解析库存状态文案
京东的库存表述:
- "有货" → "有货"
- "无货" / "暂时缺货" → "无货"
- "预约" / "即将开售" → "预约"
- "到货通知" → "补货中"
"""
text = text.strip()
if '有货' in text:
return "有货"
elif '无货' in text or '缺货' in text:
return "无货"
elif '预约' in text or '即将开售' in text:
return "预约"
elif '到货通知' in text:
return "补货中"
else:
return text if text else "未知"
# 工厂函数:根据平台创建对应的爬虫实例
def create_scraper(platform: str) -> PriceScraper:
"""
爬虫工厂
Args:
platform: 平台标识(JD/Taobao/PDD)
Returns:
对应的爬虫实例
"""
scrapers = {
'JD': JDPriceScraper,
# 'Taobao': TaobaoPriceScraper, # 可扩展
# 'PDD': PDDPriceScraper,
}
scraper_class = scrapers.get(platform)
if not scraper_class:
raise ValueError(f"不支持的平台: {platform}")
return scraper_class()
7️⃣ 核心实现:数据存储层(Storage)
数据库设计(storage.py)
python
"""
数据存储模块:管理价格历史数据的增删改查
使用SQLite作为轻量级时序数据库
"""
import sqlite3
import pandas as pd
import logging
from typing import List, Dict, Optional
from datetime import datetime, timedelta
from config import DATABASE_PATH, CSV_EXPORT_PATH
logger = logging.getLogger(__name__)
class PriceStorage:
"""
价格历史数据存储
设计要点:
1. 时序数据特点:只增不改,按时间查询
2. 复合主键:(product_id, captured_at) 保证唯一性
3. 化:时间范围查询、商品ID查询
"""
def __init__(self, db_path: str = DATABASE_PATH):
"""
初始化数据库连接
Args:
db_path: 数据库文件路径
"""
self.db_path = db_path
self.conn = None
self.cursor = None
self._connect()
self._create_tables()
def _connect(self):
"""
建立数据库连接
配置说明:
- check_same_thread=False: 允许多线程访问(APScheduler需要)
- timeout=30: 等待锁释放的时间
"""
try:
self.conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=30.0
)
# 启用WAL模式:提升并发性能
self.conn.execute('PRAGMA journal_mode=WAL')
# 启用外键约束
self.conn.execute('PRAGMA foreign_keys=ON')
self.cursor = self.conn.cursor()
logger.info(f"✓ 数据库连接成功: {self.db_path}")
except sqlite3.Error as e:
logger.error(f"数据库连接失败: {e}")
raise
def _create_tables(self):
"""
创建数据表
表设计:
1. price_history - 价格历史主表
2. alert_log - 告警记录表
3. monitor_config - 监控配置表(可选)
"""
# === 价格历史表 ===
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id VARCHAR(50) NOT NULL,
product_name TEXT NOT NULL,
price DECIMAL(10,2) NOT NULL,
original_price DECIMAL(10,2),
stock_status VARCHAR(20),
promotion_tag TEXT,
platform VARCHAR(20) NOT NULL,
data_source VARCHAR(50),
url TEXT,
captured_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- 复合唯一约束:同一商品同一时间只记录一次
UNIQUE(product_id, captured_at)
)
''')
# 创建索引:加速按商品ID和时间查询
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_product_time
ON price_history(product_id, captured_at DESC)
''')
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_platform_time
ON price_history(platform, captured_at DESC)
''')
# === 告警记录表 ===
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS alert_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id VARCHAR(50) NOT NULL,
product_name TEXT,
alert_type VARCHAR(20) NOT NULL, -- price_drop, stock_change, abnormal
old_value DECIMAL(10,2),
new_value DECIMAL(10,2),
threshold_value DECIMAL(10,2),
message TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES price_history(product_id)
)
''')
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_alert_time
ON alert_log(sent_at DESC)
''')
self.conn.commit()
logger.info("✓ 数据表初始化完成")
def insert_price_record(self, data: Dict) -> bool:
"""
插入单条价格记录
Args:
data: 商品信息字典(来自scraper)
Returns:
是否插入成功
去重逻辑:
- 使用 INSERT OR IGNORE 忽略重复记录
- 重复定义:同一product_id + captured_at
"""
try:
self.cursor.execute('''
INSERT OR IGNORE INTO price_history
(product_id, product_name, price, original_price,
stock_status, promotion_tag, platform, data_source, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
data['product_id'],
data['product_name'],
data['price'],
data.get('original_price'),
data.get('stock_status', '未知'),
data.get('promotion_tag', ''),
data['platform'],
data.get('data_source', 'HTML'),
data['url']
))
# 检查是否真正插入(rowcount=1表示插入成功)
if self.cursor.rowcount > 0:
self.conn.commit()
logger.info(f"✓ 价格记录已保存: {data['product_name']} - ¥{data['price']}")
return True
else:
logger.debug(f"记录已存在,跳过: {data['product_id']}")
return False
except sqlite3.IntegrityError as e:
logger.warning(f"数据完整性错误: {e}")
return False
except Exception as e:
logger.error(f"插入失败: {e}", exc_info=True)
self.conn.rollback()
return False
def get_latest_price(self, product_id: str) -> Optional[Dict]:
"""
获取商品最新价格记录
Args:
product_id: 商品SKU
Returns:
最新记录字典 或 None
"""
try:
self.cursor.execute('''
SELECT
product_id, product_name, price, original_price,
stock_status, promotion_tag, captured_at
FROM price_history
WHERE product_id = ?
ORDER BY captured_at DESC
LIMIT 1
''', (product_id,))
row = self.cursor.fetchone()
if row:
return {
'product_id': row[0],
'product_name': row[1],
'price': row[2],
'original_price': row[3],
'stock_status': row[4],
'promotion_tag': row[5],
'captured_at': row[6]
}
return None
except Exception as e:
logger.error(f"查询最新价格失败: {e}")
return None
def get_price_history(self, product_id: str, days: int = 30) -> List[Dict]:
"""
获取商品的历史价格记录
Args:
product_id: 商品SKU
days: 查询最近N天的数据
Returns:
记录列表,按时间升序
"""
try:
# 计算起始时间
start_date = datetime.now() - timedelta(days=days)
self.cursor.execute('''
SELECT
captured_at, price, original_price, stock_status
FROM price_history
WHERE product_id = ? AND captured_at >= ?
ORDER BY captured_at ASC
''', (product_id, start_date))
rows = self.cursor.fetchall()
history = []
for row in rows:
history.append({
'captured_at': row[0],
'price': row[1],
'original_price': row[2],
'stock_status': row[3]
})
logger.debug(f"查询到 {len(history)} 条历史记录")
return history
except Exception as e:
logger.error(f"查询历史价格失败: {e}")
return []
def calculate_price_change(self, product_id: str) -> Optional[Dict]:
"""
计算价格变化
对比最新价格与前一次价格,计算:
- 绝对变化(元)
- 相对变化(百分比)
Returns:
{'change_amount': 50.0, 'change_rate': 0.05, 'direction': 'up'}
"""
try:
# 获取最近两条记录
self.cursor.execute('''
SELECT price, captured_at
FROM price_history
WHERE product_id = ?
ORDER BY captured_at DESC
LIMIT 2
''', (product_id,))
rows = self.cursor.fetchall()
if len(rows) < 2:
logger.debug("历史记录不足,无法计算变化")
return None
new_price = rows[0][0]
old_price = rows[1][0]
change_amount = new_price - old_price
change_rate = change_amount / old_price if old_price > 0 else 0
direction = 'up' if change_amount > 0 else 'down' if change_amount < 0 else 'stable'
return {
'old_price': old_price,
'new_price': new_price,
'change_amount': round(change_amount, 2),
'change_rate': round(change_rate, 4),
'direction': direction
}
except Exception as e:
logger.error(f"计算价格变化失败: {e}")
return None
def log_alert(self, product_id: str, alert_type: str,
message: str, old_value: float = None,
new_value: float = None, threshold: float = None):
"""
记录告警日志
Args:
product_id: 商品ID
alert_type: 告警类型(price_drop/stock_change/abnormal)
message: 告警消息
old_value: 旧值
new_value: 新值
threshold: 阈值
"""
try:
# 获取商品名称
latest = self.get_latest_price(product_id)
product_name = latest['product_name'] if latest else None
self.cursor.execute('''
INSERT INTO alert_log
(product_id, product_name, alert_type, old_value,
new_value, threshold_value, message)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
product_id,
product_name,
alert_type,
old_value,
new_value,
threshold,
message
))
self.conn.commit()
logger.info(f"✓ 告警日志已记录: {alert_type} - {message}")
except Exception as e:
logger.error(f"记录告警日志失败: {e}")
def export_to_csv(self, days: int = 30) -> str:
"""
导出历史数据为CSV
Args:
days: 导出最近N天的数据
Returns:
CSV文件路径
"""
try:
start_date = datetime.now() - timedelta(days=days)
# 使用pandas直接从数据库读取
query = '''
SELECT
product_name AS 商品名称,
price AS 当前价格,
original_price AS 原价,
stock_status AS 库存状态,
promotion_tag AS 促销信息,
platform AS 平台,
captured_at AS 采集时间
FROM price_history
WHERE captured_at >= ?
ORDER BY product_name, captured_at DESC
'''
df = pd.read_sql_query(query, self.conn, params=(start_date,))
# 导出CSV(使用utf-8-sig编码兼容Excel)
df.to_csv(CSV_EXPORT_PATH, index=False, encoding='utf-8-sig')
logger.info(f"✓ 已导出 {len(df)} 条记录到: {CSV_EXPORT_PATH}")
return CSV_EXPORT_PATH
except Exception as e:
logger.error(f"导出CSV失败: {e}")
return None
def get_statistics(self) -> Dict:
"""
获取统计信息
Returns:
统计数据字典
"""
stats = {}
try:
# 总记录数
self.cursor.execute('SELECT COUNT(*) FROM price_history')
stats['total_records'] = self.cursor.fetchone()[0]
# 监控商品数
self.cursor.execute('SELECT COUNT(DISTINCT product_id) FROM price_history')
stats['total_products'] = self.cursor.fetchone()[0]
# 最早记录时间
self.cursor.execute('SELECT MIN(captured_at) FROM price_history')
earliest = self.cursor.fetchone()[0]
stats['earliest_record'] = earliest if earliest else 'N/A'
# 最新记录时间
self.cursor.execute('SELECT MAX(captured_at) FROM price_history')
latest = self.cursor.fetchone()[0]
stats['latest_record'] = latest if latest else 'N/A'
# 今日告警数
self.cursor.execute('''
SELECT COUNT(*) FROM alert_log
WHERE DATE(sent_at) = DATE('now')
''')
stats['today_alerts'] = self.cursor.fetchone()[0]
logger.debug(f"统计信息: {stats}")
return stats
except Exception as e:
logger.error(f"获取统计信息失败: {e}")
return stats
def close(self):
"""关闭数据库连接"""
if self.conn:
self.conn.close()
logger.info("数据库连接已关闭")
8️⃣ 核心实现:调度层(Scheduler)
APScheduler调度器(scheduler.py)
python
"""
定时任务调度模块
使用APScheduler实现灵活的任务调度
"""
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
import logging
from datetime import datetime
from config import SCHEDULE_CONFIG, MONITORED_PRODUCTS
from scraper import create_scraper
from storage import PriceStorage
from alert import AlertManager
logger = logging.getLogger(__name__)
class PriceMonitorScheduler:
"""
价格监控调度器
功能:
1. 定时执行监控任务
2. 任务失败时自动重试
3. 记录任务执行日志
4. 支持动态添加/删除任务
"""
def __init__(self):
"""
初始化调度器
BackgroundScheduler vs BlockingScheduler:
- Background: 后台运行,不阻塞主线程(推荐)
- Blocking: 阻塞主线程,直到调度器停止
"""
self.scheduler = BackgroundScheduler(
timezone='Asia/Shanghai', # 设置时区
job_defaults={
'coalesce': True, # 合并堆积的任务
'max_instances': 3, # 同一任务最多3个实例并发
'misfire_grace_time': 300 # 错过执仍执行
}
)
self.storage = PriceStorage()
self.alert_manager = AlertManager(self.storage)
# 监听任务执行事件
self.scheduler.add_listener(
self._job_executed_listener,
EVENT_JOB_EXECUTED | EVENT_JOB_ERROR
)
def _job_executed_listener(self, event):
"""
任务执行事件监听器
记录任务执行结果,便于排查问题
"""
if event.exception:
logger.error(f"任务执行失败: {event.job_id}, 错误: {event.exception}")
else:
logger.info(f"任务执行成功: {event.job_id}")
def start(self):
"""
启动调度器
流程:
1. 添加所有定时任务
2. 启动调度器
3. 打印任务列表
"""
logger.info("=" * 60)
logger.info("价格监控系统启动中...")
logger.info("=" * 60)
# 添加主监控任务
self._add_main_monitor_job()
# 添加报告生成任务
self._add_report_jobs()
# 启动调度器
self.scheduler.start()
# 打印任务列表
self._print_job_list()
logger.info("✓ 调度器已启动,等待任务触发...")
def _add_main_monitor_job(self):
"""
添加主监控任务
根据配置文件中的schedule设置触发器:
- Cron: 按时间点执行(如每天8点、20点)
- Interval: 按时间间隔执行(如每2小时)
"""
config = SCHEDULE_CONFIG['main_monitor']
if config['trigger'] == 'cron':
trigger = CronTrigger(
hour=config['hour'],
minute=config['minute'],
second=config.get('second', '0'),
timezone='Asia/Shanghai'
)
self.scheduler.add_job(
func=self.monitor_all_products,
trigger=trigger,
id='main_monitor',
name='主监控任务',
replace_existing=True
)
logger.info(f"✓ 主监控任务已添加: 每天{config['hour']}点执行")
# 如果启用了大促高频监控
promo_config = SCHEDULE_CONFIG.get('promo_monitor', {})
if promo_config.get('enabled', False):
interval_trigger = IntervalTrigger(
hours=promo_config.get('hours', 2),
timezone='Asia/Shanghai'
)
self.scheduler.add_job(
func=self.monitor_all_products,
trigger=interval_trigger,
id='promo_monitor',
name='大促高频监控',
replace_existing=True
)
logger.info(f"✓ 大促监控已启用: 每{promo_config['hours']}小时执行")
def _add_report_jobs(self):
"""
添加报告生成任务
1. 每日报告:凌晨1点生成昨日数据汇总
2. 每周分析:周日晚10点生成周报
"""
# 每日报告
daily_config = SCHEDULE_CONFIG['daily_report']
daily_trigger = CronTrigger(
hour=daily_config['hour'],
minute=daily_config['minute'],
timezone='Asia/Shanghai'
)
self.scheduler.add_job(
func=self.generate_daily_report,
trigger=daily_trigger,
id='daily_report',
name='每日报告生成',
replace_existing=True
)
# 每周分析
weekly_config = SCHEDULE_CONFIG['weekly_analysis']
weekly_trigger = CronTrigger(
day_of_week=weekly_config['day_of_week'],
hour=weekly_config['hour'],
minute=weekly_config['minute'],
timezone='Asia/Shanghai'
)
self.scheduler.add_job(
func=self.generate_weekly_analysis,
trigger=weekly_trigger,
id='weekly_analysis',
name='每周趋势分析',
replace_existing=True
)
logger.info("✓ 报告生成任务已添加")
def monitor_all_products(self):
"""
监控所有商品(核心业务逻辑)
流程:
1. 遍历配置文件中的商品列表
2. 调用爬虫获取最新数据
3. 存储到数据库
4. 检查是否需要告警
"""
logger.info("\n" + "=" * 60)
logger.info(f"开始监控任务 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info("=" * 60)
success_count = 0
fail_count = 0
for product_config in MONITORED_PRODUCTS:
# 跳过禁用的商品
if not product_config.get('enabled', True):
logger.debug(f"跳过已禁用商品: {product_config['product_name']}")
continue
try:
# 1. 创建对应平台的爬虫
scraper = create_scraper(product_config['platform'])
# 2. 爬取商品信息
product_info = scraper.fetch_product_info(product_config)
if not product_info:
logger.warning(f"商品信息获取失败: {product_config['product_name']}")
fail_count += 1
continue
# 3. 存储到数据库
if self.storage.insert_price_record(product_info):
success_count += 1
# 4. 检查告警条件
self._check_alert_conditions(product_config, product_info)
except Exception as e:
logger.error(f"监控商品时出错: {product_config['product_name']}, {e}", exc_info=True)
fail_count += 1
# 输出本次监控汇总
logger.info("\n" + "-" * 60)
logger.info(f"本次监控完成: 成功{success_count}个, 失败{fail_count}个")
logger.info("-" * 60 + "\n")
def _check_alert_conditions(self, product_config: Dict, product_info: Dict):
"""
检查是否满足告警条件
告警类型:
1. 价格低于阈值
2. 价格异常波动(涨跌幅超过15%)
3. 库存状态变化(无货变有货)
"""
product_id = product_config['product_id']
current_price = product_info['price']
threshold = product_config.get('alert_threshold')
# === 1. 价格阈值告警 ===
if threshold and current_price < threshold:
# 避免重复告警:检查最近1小时是否已告警
if not self._is_recently_alerted(product_id, 'price_drop', hours=1):
message = (
f"🎉 好消息!商品降价了!\n\n"
f"商品:{product_info['product_name']}\n"
f"当前价格:¥{current_price}\n"
f"告警阈值:¥{threshold}\n"
f"链接:{product_info['url']}"
)
self.alert_manager.send_alert(
product_id=product_id,
alert_type='price_drop',
message=message,
new_value=current_price,
threshold=threshold
)
logger.info(f"✓ 已发送降价告警: {product_config['product_name']}")
# === 2. 价格异常波动告警 ===
price_change = self.storage.calculate_price_change(product_id)
if price_change:
change_rate = abs(price_change['change_rate'])
# 涨跌幅超过15%视为异常
if change_rate > 0.15:
if not self._is_recently_alerted(product_id, 'abnormal', hours=6):
direction_text = "上涨" if price_change['direction'] == 'up' else "下跌"
message = (
f"⚠️ 价格异常波动!\n\n"
f"商品:{product_info['product_name']}\n"
f"变化:¥{price_change['old_price']} → ¥{price_change['new_price']}\n"
f"{direction_text}幅度:{change_rate*100:.1f}%\n"
f"可能原因:促销活动或数据异常"
)
self.alert_manager.send_alert(
product_id=product_id,
alert_type='abnormal',
message=message,
old_value=price_change['old_price'],
new_value=price_change['new_price']
)
# === 3. 库存状态变化告警 ===
latest_record = self.storage.get_latest_price(product_id)
if latest_record:
old_stock = latest_record.get('stock_status')
new_stock = product_info.get('stock_status')
# 从无货变有货时通知
if old_stock in ['无货', '补货中'] and new_stock == '有货':
if not self._is_recently_alerted(product_id, 'stock_change', hours=2):
message = (
f"✅ 商品补货了!\n\n"
f"商品:{product_info['product_name']}\n"
f"库存状态:{old_stock} → {new_stock}\n"
f"当前价格:¥{current_price}\n"
f"快去下单:{product_info['url']}"
)
self.alert_manager.send_alert(
product_id=product_id,
alert_type='stock_change',
message=message
)
def _is_recently_alerted(self, product_id: str, alert_type: str, hours: int = 1) -> bool:
"""
检查最近N小时内是否已发送过同类告警
避免短时间内重复发送告警骚扰用户
"""
try:
self.storage.cursor.execute('''
SELECT COUNT(*) FROM alert_log
WHERE product_id = ?
AND alert_type = ?
AND sent_at >= datetime('now', '-' || ? || ' hours')
''', (product_id, alert_type, hours))
count = self.storage.cursor.fetchone()[0]
return count > 0
except Exception as e:
logger.error(f"查询告警记录失败: {e}")
return False
def generate_daily_report(self):
"""
生成每日报告
内容:
1. 昨日价格变化汇总
2. 导出CSV文件
3. 发送报告邮件(可选)
"""
logger.info("\n" + "=" * 60)
logger.info("开始生成每日报告")
logger.info("=" * 60)
try:
# 导出最近30天数据
csv_path = self.storage.export_to_csv(days=30)
if csv_path:
logger.info(f"✓ CSV文件已生成: {csv_path}")
# 获取统计信息
stats = self.storage.get_statistics()
report_message = (
f"📊 每日监控报告\n\n"
f"报告时间:{datetime.now().strftime('%Y-%m-%d')}\n\n"
f"监控商品数:{stats.get('total_products', 0)}\n"
f"总记录数:{stats.get('total_records', 0)}\n"
f"今日告警数:{stats.get('today_alerts', 0)}\n\n"
f"详细数据请查看附件CSV文件"
)
logger.info(report_message)
# 可选:发送报告邮件
# self.alert_manager.send_email(
# subject="价格监控每日报告",
# content=report_message,
# attachments=[csv_path]
# )
except Exception as e:
logger.error(f"生成每日报告失败: {e}", exc_info=True)
def generate_weekly_analysis(self):
"""
生成每周趋势分析
内容:
1. 价格趋势图
2. 最低价/最高价统计
3. 促销活动汇总
"""
logger.info("\n" + "=" * 60)
logger.info("开始生成每周分析")
logger.info("=" * 60)
try:
from analyzer import PriceAnalyzer
analyzer = PriceAnalyzer(self.storage)
# 为每个监控商品生成趋势图
for product_config in MONITORED_PRODUCTS:
if not product_config.get('enabled', True):
continue
product_id = product_config['product_id']
# 生成30天趋势图
chart_path = analyzer.plot_price_trend(
product_id=product_id,
days=30
)
if chart_path:
logger.info(f"✓ 趋势图已生成: {chart_path}")
logger.info("✓ 每周分析完成")
except Exception as e:
logger.error(f"生成每周分析失败: {e}", exc_info=True)
def _print_job_list(self):
"""打印当前所有任务"""
jobs = self.scheduler.get_jobs()
logger.info("\n" + "=" * 60)
logger.info("当前定时任务列表:")
logger.info("=" * 60)
for job in jobs:
logger.info(f" - {job.name} ({job.id})")
logger.info(f" 触发器: {job.trigger}")
logger.info(f" 下次执行: {job.next_run_time}")
logger.info("")
def stop(self):
"""停止调度器"""
logger.info("正在停止调度器...")
self.scheduler.shutdown(wait=True)
self.storage止")
9️⃣ 核心实现:告警层(Alert)
告警管理器(alert.py)
python
"""
告警模块:支持邮件、企业微信等多种通知方式
"""
import yagmail
import requests
import logging
from typing import List, Optional
from config import EMAIL_CONFIG, WECHAT_CONFIG, ALERT_METHOD
logger = logging.getLogger(__name__)
class AlertManager:
"""
告警管理器
支持的通知渠道:
1. 邮件(SMTP)
2. 企业微信机器人
3. 钉钉机器人(可扩展)
"""
def __init__(self, storage):
"""
初始化告警管理器
Args:
storage: 数据库存储对象(用于记录告警日志)
"""
self.storage = storage
self.email_enabled = EMAIL_CONFIG.get('enabled', False)
self.wechat_enabled = WECHAT_CONFIG.get('enabled', False)
# 初始化邮件客户端
if self.email_enabled:
self._init_email_client()
def _init_email_client(self):
"""
初始化yagmail客户端
配置说明:
- SMTP服务器:如smtp.qq.com、smtp.gmail.com
- 端口:465(SSL)或587(TLS)
- 密码:使用授权码而非真实密码
"""
try:
self.email_client = yagmail.SMTP(
user=EMAIL_CONFIG['sender'],
password=EMAIL_CONFIG['password'],
host=EMAIL_CONFIG['smtp_server'],
port=EMAIL_CONFIG['smtp_port'],
smtp_ssl=EMAIL_CONFIG.get('ssl', True)
)
logger.info("✓ 邮件客户端初始化成功")
except Exception as e:
logger.error(f"邮件客户端初始化失败: {e}")
self.email_enabled = False
def send_alert(self, product_id: str, alert_type: str,
message: str, old_value: float = None,
new_value: float = None, threshold: float = None):
"""
发送告警(统一入口)
Args:
product_id: 商品ID
alert_type: 告警类型
message: 告警消息
old_value: 旧值
new_value: 新值
threshold: 阈值
"""
# 记录告警日志到数据库
self.storage.log_alert(
product_id=product_id,
alert_type=alert_type,
message=message,
old_value=old_value,
new_value=new_value,
threshold=threshold
)
# 根据配置选择通知渠道
if ALERT_METHOD in ['email', 'both']:
self.send_email_alert(message)
if ALERT_METHOD in ['wechat', 'both']:
self.send_wechat_alert(message)
def send_email_alert(self, message: str,
subject: str = "价格监控告警",
attachments: List[str] = None):
"""
发送邮件告警
Args:
message: 邮件正文
subject: 邮件主题
attachments: 附件列表
"""
if not self.email_enabled:
logger.warning("邮件功能未启用")
return
try:
receivers = EMAIL_CONFIG['receivers']
# 构造HTML格式邮件(更美观)
html_message = self._format_html_message(message)
self.email_client.send(
to=receivers,
subject=subject,
contents=[html_message],
attachments=attachments
)
logger.info(f"✓ 邮件已发送到: {', '.join(receivers)}")
except Exception as e:
logger.error(f"发送邮件失败: {e}", exc_info=True)
def send_wechat_alert(self, message: str):
"""
发送企业微信机器人消息
API文档:https://developer.work.weixin.qq.com/document/path/91770
消息格式:
{
"msgtype": "text",
"text": {
"content": "告警消息"
}
}
"""
if not self.wechat_enabled:
logger.warning("企业微信功能未启用")
return
try:
webhook_url = WECHAT_CONFIG['webhook_url']
payload = {
"msgtype": "text",
"text": {
"content": message
}
}
response = requests.post(
webhook_url,
json=payload,
timeout=10
)
if response.status_code == 200:
result = response.json()
if result.get('errcode') == 0:
logger.info("✓ 企业微信消息已发送")
else:
logger.error(f"企业微信消息发送失败: {result.get('errmsg')}")
else:
logger.error(f"企业微信API请求失败: {response.status_code}")
except Exception as e:
logger.error(f"发送企业微信消息失败: {e}", exc_info=True)
def _format_html_message(self, text: str) -> str:
"""
将纯文本格式化为HTML邮件
添加样式使邮件更美观
"""
html = f"""
<html>
<head>
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 20px auto;
padding: 20px;
borderpx;
background-color: #f9f9f9;
}}
.header {{
font-size: 18px;
font-weight: bold;
color: #econtent {{
white-space: pre-wrap;
background-color: white;
padding: 15px;
border-radius: 3px;
}}
.footer {{
margin-top: 20px;
font-size: 12px;
color: #999;
text-align: center;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">📢 价格监控系统</div>
<div class="content">{text}</div>
<div class="footer">
此邮件由价格监控系统自动发送,请勿回复
</div>
</div>
</body>
</html>
"""
return html
🔟 核心实现:分析层(Analyzer)
数据分析模块(analyzer.py)
python
"""
数据分析与可视化模块
生成价格趋势图、统计报表等
"""
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import seaborn as sns
import numpy as np
import logging
from datetime import datetime
from typing import Optional
from config import CHART_DIR, CHART_CONFIG
import os
# 设置中文字体(解决matplotlib中文显示问题)
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
logger = logging.getLogger(__name__)
class PriceAnalyzer:
"""
价格数据分析器
功能:
1. 绘制价格趋势图
2. 计算统计指标(均值、极值、标准差)
3. 检测价格异常
"""
def __init__(self, storage):
"""
初始化分析器
Args:
storage: 数据库存储对象
"""
self.storage = storage
# 确保图表目录存在
os.makedirs(CHART_DIR, exist_ok=True)
# 设置绘图风格
plt.style.use(CHART_CONFIG.get('style', 'seaborn-v0_8'))
def plot_price_trend(self, product_id: str, days: int = 30) -> Optional[str]:
"""
绘制价格趋势图
Args:
product_id: 商品ID
days: 查询最近N天的数据
Returns:
图表文件路径 或 None
图表包含:
1. 价格折线图(当前价+原价)
2. 库存状态标注
3. 统计信息文本框
"""
try:
# 获取历史数据
history = self.storage.get_price_history(product_id, days)
if len(history) < 2:
logger.warning(f"商品 {product_id} 历史数据不足,无法绘图")
return None
# 提取数据
timestamps = [datetime.strptime(item['captured_at'], '%Y-%m-%d %H:%M:%S')
for item in history]
prices = [item['price'] for item in history]
original_prices = [item['original_price'] or item['price']
for item in history]
# 获取商品名称
latest = self.storage.get_latest_price(product_id)
product_name = latest['product_name'] if latest else product_id
# 创建图表
fig, ax = plt.subplots(
figsize=CHART_CONFIG.get('figure_size', (12, 6)),
dpi=CHART_CONFIG.get('dpi', 100)
)
# === 绘制价格线 ===
ax.plot(timestamps, prices,
marker='o', markersize=4, linewidth=2,
color='#e74c3c', label='当前价格')
ax.plot(timestamps, original_prices,
marker='s', markersize=3, linewidth=1,
color='#95a5a6', linestyle='--', label='原价', alpha=0.7)
# === 标注最低价和最高价 ===
min_price = min(prices)
max_price = max(prices)
min_idx = prices.index(min_price)
max_idx = prices.index(max_price)
ax.annotate(f'最低价\n¥{min_price}',
xy=(timestamps[min_idx], min_price),
xytext=(10, -30), textcoords='offset points',
fontsize=9, color='green',
bbox=dict(boxstyle='round,pad=0.5', facecolor='lightgreen', alpha=0.8),
arrowprops=dict(arrowstyle='->', color='green', lw=1.5))
ax.annotate(f'最高价\n¥{max_price}',
xy=(timestamps[max_idx], max_price),
xytext=(10, 30), textcoords='offset points',
fontsize=9, color='red',
bbox=dict(boxstyle='round,pad=0.5', facecolor='lightcoral', alpha=0.8),
arrowprops=dict(arrowstyle='->', color='red', lw=1.5))
# === 标注无货时段(灰色背景) ===
for i, item in enumerate(history):
if item['stock_status'] == '无货':
if i < len(timestamps) - 1:
ax.axvspan(timestamps[i], timestamps[i+1],
alpha=0.2, color='gray')
# === 设置坐标轴 ===
ax.set_xlabel('日期', fontsize=12)
ax.set_ylabel('价格(元)', fontsize=12)
ax.set_title(f'{product_name}\n价格趋势(最近{days}天)',
fontsize=14, fontweight='bold', pad=20)
# 格式化X轴日期
ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, days//10)))
plt.xticks(rotation=45)
# 显示网格
if CHART_CONFIG.get('show_grid', True):
ax.grid(True, alpha=0.3, linestyle='--')
# 添加图例
ax.legend(loc='upper right', fontsize=10)
# === 添加统计信息文本框 ===
stats_text = self._calculate_statistics(prices)
ax.text(0.02, 0.98, stats_text,
transform=ax.transAxes,
fontsize=9, verticalalignment='top',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
# 调整布局
plt.tight_layout()
# 保存图表
chart_filename = f"trend_{product_id}_{datetime.now().strftime('%Y%m%d')}.png"
chart_path = os.path.join(CHART_DIR, chart_filename)
plt.savefig(chart_path, dpi=CHART_CONFIG.get('dpi', 100), bbox_inches='tight')
plt.close()
logger.info(f"✓ 趋势图已保存: {chart_path}")
return chart_path
except Exception as e:
logger.error(f"绘制趋势图失败: {e}", exc_info=True)
return None
def _calculate_statistics(self, prices: list) -> str:
"""
计算统计指标
Returns:
格式化的统计文本
"""
avg_price = np.mean(prices)
std_price = np.std(prices)
min_price = np.min(prices)
max_price = np.max(prices)
# 计算价格稳定性(变异系数)
cv = (std_price / avg_price) * 100 if avg_price > 0 else 0
stability = "稳定" if cv < 5 else "波动较大" if cv < 10 else "极不稳定"
stats_text = (
f"统计信息\n"
f"平均价: ¥{avg_price:.2f}\n"
f"标准差: ¥{std_price:.2f}\n"
f"价格区间: ¥{min_price} - ¥{max_price}\n"
f"稳定性: {stability} (CV={cv:.1f}%)"
)
return stats_text
def detect_price_anomaly(self, product_id: str, threshold: float = 2.0) -> dict:
"""
检测价格异常
使用统计学方法(Z-score)检测异常值
Args:
product_id: 商品ID
threshold: Z-score阈值(通常取2或3)
Returns:
异常检测结果字典
"""
try:
history = self.storage.get_price_history(product_id, days=30)
if len(history) < 5:
return {'has_anomaly': False, 'message': '数据不足'}
prices = np.array([item['price'] for item in history])
# 计算Z-score
mean = np.mean(prices)
std = np.std(prices)
if std == 0:
return {'has_anomaly': False, 'message': '价格无变化'}
z_scores = np.abs((prices - mean) / std)
# 找出异常值
anomaly_indices = np.where(z_scores > threshold)[0]
if len(anomaly_indices) > 0:
anomalies = []
for idx in anomaly_indices:
anomalies.append({
'date': history[idx]['captured_at'],
'price': history[idx]['price'],
'z_score': z_scores[idx]
})
return {
'has_anomaly': True,
'count': len(anomalies),
'anomalies': anomalies
}
return {'has_anomaly': False, 'message': '未检测到异常'}
except Exception as e:
logger.error(f"异常检测失败: {e}")
return {'has_anomaly': False, 'message': f'检测失败: {e}'}
def compare_platforms(self, product_name_keyword: str) -> Optional[str]:
"""
对比不同平台的同款商品价格
Args:
product_name_keyword: 商品名称关键词
Returns:
对比图表路径
"""
# TODO: 实现跨平台价格对比
# 需要先在配置中添加多个平台的同款商品
pass
# === 辅助函数:生成价格分析报告(Markdown格式)===
def generate_price_report(storage, product_id: str, days: int = 30) -> str:
"""
生成价格分析报告(Markdown格式)
Args:
storage: 数据库对象
product_id: 商品ID
days: 分析天数
Returns:
Markdown格式的报告文本
"""
history = storage.get_price_history(product_id, days)
if not history:
return "# 暂无数据\n"
prices = [item['price'] for item in history]
# 计算指标
current_price = prices[-1]
avg_price = np.mean(prices)
min_price = np.min(prices)
max_price = np.max(prices)
# 价格趋势(近7天)
if len(prices) >= 7:
recent_trend = "上涨" if prices[-1] > prices[-7] else "下跌"
else:
recent_trend = "数据不足"
# 生成Markdown报告
report = f"""
# 价格分析报告
**商品ID**: {product_id}
**分析周期**: 最近{days}天
**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
---
## 📊 价格概览
- **当前价格**: ¥{current_price}
- **平均价格**: ¥{avg_price:.2f}
- **历史最低**: ¥{min_price}
- **历史最高**: ¥{max_price}
- **价格区间**: ¥{max_price - min_price}
## 📈 趋势分析
- **近7日趋势**: {recent_trend}
- **数据点数**: {len(prices)}
- **更新频率**: 每天2次
## 💡 购买建议
"""
# 购买建议逻辑
if current_price <= min_price * 1.05:
report += "✅ **建议购买**:当前价格接近历史最低,是入手好时机!\n"
elif current_price >= max_price * 0.95:
report += "⚠️ **建议等待**:当前价格接近历史最高,建议观望。\n"
else:
report += "⏸️ **持续观望**:价格处于中位水平,可继续监控。\n"
return report
1️⃣1️⃣ 运行方式与结果展示
主程序入口(main.py)
python
"""
价格监控系统主程序
"""
import logging
import logging.config
import signal
import sys
from scheduler import PriceMonitorScheduler
from config import LOGGING_CONFIG
# 配置日志
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger(__name__)
# 全局变量:调度器实例
scheduler = None
def signal_handler(sig, frame):
"""
处理Ctrl+C信号
优雅关闭程序
"""
logger.info("\n检测到中断信号,正在关闭系统...")
if scheduler:
scheduler.stop()
logger.info("系统已安全退出")
sys.exit(0)
def main():
"""
主函数
流程:
1. 注册信号处理器
2. 初始化调度器
3. 启动定时任务
4. 保持运行直到手动中断
"""
global scheduler
# 注册Ctrl+C处理器
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
# 创建并启动调度器
scheduler = PriceMonitorScheduler()
scheduler.start()
# 打印操作提示
print("\n" + "=" * 60)
print("价格监控系统运行中...")
print("按 Ctrl+C 可安全退出")
print("=" * 60 + "\n")
# 保持主线程运行
# BackgroundScheduler在后台线程执行任务
signal.pause() # 等待信号
except KeyboardInterrupt:
logger.info("用户手动中断")
except Exception as e:
logger.error(f"程序异常退出: {e}", exc_info=True)
finally:
if scheduler:
scheduler.stop()
if __name__ == "__main__":
main()
启动命令
bash
# 1. 激活虚拟环境
source price_monitor_env/bin/activate
# 2. 确认配置文件正确
# 编辑 config.py,填写邮箱、商品列表等配置
# 3. 测试单次运行(调试用)
python -c "
from scheduler import PriceMonitorScheduler
scheduler = PriceMonitorScheduler()
scheduler.monitor_all_products()
"
# 4. 启动完整系统
python main.py
# 5. 后台运行(Linux/Mac)
nohup python main.py > output.log 2>&1 &
# 6. 使用systemd守护进程(推荐生产环境)
sudo systemctl start price_monitor.service
运行日志示例
json
2024-01-28 20:00:00 - [INFO] - __main__ - ============================================================
2024-01-28 20:00:00 - [INFO] - __main__ - 价格监控系统启动中...
2024-01-28 20:00:00 - [INFO] - __main__ - ============================================================
2024-01-28 20:00:00 - [INFO] - storage - ✓ 数据库连接成功: /path/to/price_history.db
2024-01-28 20:00:00 - [INFO] - storage - ✓ 数据表初始化完成
2024-01-28 20:00:00 - [INFO] - alert - ✓ 邮件客户端初始化成功
2024-01-28 20:00:00 - [INFO] - scheduler - ✓ 主监控任务已添加: 每天8,20点执行
2024-01-28 20:00:00 - [INFO] - scheduler - ✓ 报告生成任务已添加
============================================================
当前定时任务列表:
============================================================
- 主监控任务 (main_monitor)
触发器: cron[hour='8,20', minute='0', second='0']
下次执行: 2024-01-29 08:00:00+08:00
- 每日报告生成 (daily_report)
触发器: cron[hour='1', minute='0']
下次执行: 2024-01-29 01:00:00+08:00
- 每周趋势分析 (weekly_analysis)
触发器: cron[day_of_week='sun', hour='22', minute='0']
下次执行: 2024-02-04 22:00:00+08:00
============================================================
价格监控系统运行中...
按 Ctrl+C 可安全退出
============================================================
2024-01-28 20:00:05 - [INFO] - scheduler - ✓ 调度器已启动,等待任务触发...
# === 定时任务触发 ===
2024-01-29 08:00:00 - [INFO] - scheduler -
============================================================
2024-01-29 08:00:00 - [INFO] - scheduler - 开始监控任务 - 2024-01-29 08:00:00
2024-01-29 08:00:00 - [INFO] - scheduler - ============================================================
2024-01-29 08:00:01 - [INFO] - scraper - 开始监控商品: Apple iPhone 15 Pro 256GB 黑色钛金属
2024-01-29 08:00:04 - [INFO] - scraper - ✓ 成功获取: https://item.jd.com/100012345678.html
2024-01-29 08:00:05 - [DEBUG] - scraper - 解析到商品标题: Apple iPhone 15 Pro (A3108) 256GB 黑色钛金属
2024-01-29 08:00:05 - [DEBUG] - scraper - 库存状态: 有货
2024-01-29 08:00:05 - [DEBUG] - scraper - 促销信息: 满5000减300 | 白条6期免息
2024-01-29 08:00:06 - [DEBUG] - scraper - 价格API返回: 现价=7999.0, 原价=8999.0
2024-01-29 08:00:06 - [INFO] - scraper - ✓ 商品信息获取成功: 价格=7999.0元, 库存=有货
2024-01-29 08:00:06 - [INFO] - storage - ✓ 价格记录已保存: Apple iPhone 15 Pro... - ¥7999.0
2024-01-29 08:00:07 - [INFO] - scraper - 开始监控商品: Cherry MX8.0机械键盘 青轴
2024-01-29 08:00:10 - [INFO] - scraper - ✓ 成功获取: https://item.jd.com/100087654321.html
2024-01-29 08:00:11 - [INFO] - scraper - ✓ 商品信息获取成功: 价格=389.0元, 库存=有货
2024-01-29 08:00:11 - [INFO] - storage - ✓ 价格记录已保存: Cherry MX8.0... - ¥389.0
# === 触发降价告警 ===
2024-01-29 08:00:11 - [INFO] - scheduler - ✓ 已发送降价告警: Cherry MX8.0机械键盘 青轴
2024-01-29 08:00:12 - [INFO] - alert - ✓ 邮件已发送到: notify_email@example.com
2024-01-29 08:00:12 - [INFO] - scheduler -
------------------------------------------------------------
2024-01-29 08:00:12 - [INFO] - scheduler - 本次监控完成: 成功2个, 失败0个
2024-01-29 08:00:12 - [INFO] - scheduler - ------------------------------------------------------------
2024-01-29 08:00:12 - [INFO] - scheduler - 任务执行成功: main_monitor
数据库查询示例
bash
# 1. 进入SQLite命令行
sqlite3 data/price_history.db
# 2. 查看最近10条价格记录
SELECT
product_name,
price,
stock_status,
datetime(captured_at, 'localtime') as time
FROM price_history
ORDER BY captured_at DESC
LIMIT 10;
# 输出:
Apple iPhone 15 Pro...|7999.0|有货|2024-01-29 08:00:06
Cherry MX8.0机械键盘...|389.0|有货|2024-01-29 08:00:11
Apple iPhone 15 Pro...|7999.0|有货|2024-01-28 20:00:05
Cherry MX8.0机械键盘...|399.0|有货|2024-01-28 20:00:10
...
# 3. 统计商品价格区间
SELECT
product_name,
MIN(price) as 最低价,
MAX(price) as 最高价,
AVG(price) as 平均价,
COUNT(*) as 记录数
FROM price_history
GROUP BY product_id;
# 4. 查询告警记录
SELECT
alert_type,
product_name,
message,
datetime(sent_at, 'localtime') as time
FROM alert_log
ORDER BY sent_at DESC
LIMIT 5;
CSV导出文件预览
csv
商品名称,当前价格,原价,库存状态,促销信息,平台,采集时间
Apple iPhone 15 Pro (A3108) 256GB 黑色钛金属,7999.0,8999.0,有货,满5000减300 | 白条6期免息,JD,2024-01-29 08:00:06
Cherry MX8.0机械键盘 青轴,389.0,399.0,有货,,JD,2024-01-29 08:00:11
Apple iPhone 15 Pro (A3108) 256GB 黑色钛金属,7999.0,8999.0,有货,满5000减300,JD,2024-01-28 20:00:05
Cherry MX8.0机械键盘 青轴,399.0,399.0,有货,,JD,2024-01-28 20:00:10
邮件告警效果
json
主题:价格监控告警
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📢 价格监控系统
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎉 好消息!商品降价了!
商品:Cherry MX8.0机械键盘 青轴
当前价格:¥389.0
告警阈值:¥399.0
链接:https://item.jd.com/100087654321.html
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
此邮件由价格监控系统自动发送,请勿回复
1️⃣2️⃣ 常见问题与排错
Q1: 定时任务不执行?
排查步骤:
python
# 1. 检查APScheduler是否启动
python -c "
from scheduler import PriceMonitorScheduler
scheduler = PriceMonitorScheduler()
scheduler.start()
print(scheduler.scheduler.get_jobs())
import time
time.sleep(5)
"
# 2. 检查系统时区
python -c "
from datetime import datetime
import pytz
print('系统时间:', datetime.now())
print('中国时间:', datetime.now(pytz.timezone('Asia/Shanghai')))
"
# 3. 手动触发任务测试
python -c "
from scheduler import PriceMonitorScheduler
scheduler = PriceMonitorScheduler()
scheduler.monitor_all_products() # 直接执行
"
常见原因:
- 时区配置错误(UTC vs Asia/Shanghai)
- Cron表达式写错(如
hour='8-20'应该是hour='8,20') - 调度器未保持运行(主线程退出)
Q2: 数据库锁定(database is locked)?
原因:SQLite不支持高并发写入
解决方案:
python
# 方案1:增加超时时间
conn = sqlite3.connect(db_path, timeout=30.0)
# 方案2:启用WAL模式
conn.execute('PRAGMA journal_mode=WAL')
# 方案3:使用连接池(推荐)
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
engine = create_engine(
f'sqlite:///{db_path}',
poolclass=QueuePool,
pool_size=5,
max_overflow=10
)
Q3: 邮件发送失败(535 Login Fail)?
原因:SMTP密码错误
解决:
python
# ❌ 错误:使用QQ邮箱登录密码
EMAIL_CONFIG = {
'sender': 'your_email@qq.com',
'password': 'your_qq_password' # 这是错的!
}
# ✅ 正确:使用SMTP授权码
# 1. 登录QQ邮箱
# 2. 设置 → 账户 → 开启SMTP服务
# 3. 生成授权码(16位字符)
EMAIL_CONFIG = {
'sender': 'your_email@qq.com',
'password': 'abcd efgh ijkl mnop' # SMTP授权码
}
Q4: 爬取频率过高被封IP?
诊断:
python
# 检查日志中是否有大量403/429
grep -i "403\|429" logs/monitor.log
# 检查实际请求间隔
tail -f logs/monitor.log | grep "随机延迟"
解决:
python
# 1. 增加延迟
DELAY_RANGE = (5, 10) # 从(3,8)改为(5,10)
# 2. 降低监控频率
SCHEDULE_CONFIG = {
'main_monitor': {
'trigger': 'cron',
'hour': '8,20', # 改为只在早晚各一次
}
}
# 3. 使用代理IP(付费服务)
proxies = {
'http': 'http://proxy.com:port',
'https': 'https://proxy.com:port'
}
response = session.get(url, proxies=proxies)
Q5: 价格趋势图中文显示乱码?
原因:matplotlib未找到中文字体
解决:
python
# Windows
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
# Mac
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'PingFang SC']
# Linux(需要安装字体)
# sudo apt-get install fonts-wqy-zenhei
plt.rcParams['font.sans-serif'] = ['WenQuanYi Zen Hei']
# 通用方案:指定字体文件
import matplotlib.font_manager as fm
font_path = '/path/to/SimHei.ttf'
prop = fm.FontProperties(fname=font_path)
plt.text(x, y, '中文', fontproperties=prop)
Q6: 系统重启后任务丢失?
原因:APScheduler默认使用内存存储
解决:使用持久化存储
python
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
jobstores = {
'default': SQLAlchemyJobStore(url='sqlite:///jobs.db')
}
scheduler = BackgroundScheduler(jobstores=jobstores)
1️⃣3️⃣ 进阶优化
1. 多商品批量监控优化
python
from concurrent.futures import ThreadPoolExecutor, as_completed
def monitor_with_concurrency(products: list, max_workers: int = 3):
"""
并发监控多个商品
注意:max_workers不宜过大,避免被限流
"""
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有任务
future_to_product = {
executor.submit(monitor_single_product, p): p
for p in products
}
# 收集结果
for future in as_completed(future_to_product):
product = future_to_product[future]
try:
result = future.result(timeout=60)
results.append(result)
except Exception as e:
logger.error(f"并发任务失败: {product['product_name']}, {e}")
return results
def monitor_single_product(product_config):
"""单个商品监控(可独立执行)"""
scraper = create_scraper(product_config['platform'])
return scraper.fetch_product_info(product_config)
2. 智能告警策略
python
class SmartAlertStrategy:
"""
智能告警策略
避免频繁打扰用户,只在关键时刻通知
"""
def should_alert(self, product_id: str, alert_type: str,
new_value: float, threshold: float) -> bool:
"""
判断是否应该发送告警
策略:
1. 价格降幅>5%才通知(避免小波动)
2. 同一商品24小时内只通知一次
3. 工作时间优先(避免半夜打扰)
"""
# 检查降幅
if alert_type == 'price_drop':
old_price = self._get_last_alerted_price(product_id)
if old_price:
drop_rate = (old_price - new_value) / old_price
if drop_rate < 0.05: # 降幅<5%不通知
return False
# 检查告警频率
if self._is_recently_alerted(product_id, alert_type, hours=24):
return False
# 检查时间段(可选)
from datetime import datetime
hour = datetime.now().hour
if hour < 8 or hour > 22: # 夜间静音
return False
return True
def _get_last_alerted_price(self, product_id: str) -> Optional[float]:
"""获取上次告警时的价格"""
# 从数据库查询
pass
def _is_recently_alerted(self, product_id: str,
alert_type: str, hours: int) -> bool:
"""检查最近N小时是否已告警"""
# 查询alert_log表
pass
3. 数据备份与恢复
python
import shutil
from datetime import datetime
def backup_database():
"""
备份数据库
建议每周自动备份一次
"""
try:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f'data/backups/price_history_{timestamp}.db'
# 确保备份目录存在
os.makedirs(os.path.dirname(backup_path), exist_ok=True)
# 复制数据库文件
shutil.copy2(DATABASE_PATH, backup_path)
logger.info(f"✓ 数据库已备份: {backup_path}")
# 清理7天前的旧备份
cleanup_old_backups(days=7)
return backup_path
except Exception as e:
logger.error(f"数据库备份失败: {e}")
return None
def cleanup_old_backups(days: int = 7):
"""删除N天前的备份文件"""
import glob
from datetime import timedelta
backup_dir = 'data/backups'
cutoff_date = datetime.now() - timedelta(days=days)
for backup_file in glob.glob(f'{backup_dir}/*.db'):
file_time = datetime.fromtimestamp(os.path.getmtime(backup_file))
if file_time < cutoff_date:
os.remove(backup_file)
logger.info(f"已删除旧备份: {backup_file}")
4. Web可视化界面(Flask)
python
from flask import Flask, render_template, jsonify
import json
app = Flask(__name__)
storage = PriceStorage()
@app.route('/')
def index():
"""主页:显品"""
products = []
for config in MONITORED_PRODUCTS:
latest = storage.get_latest_price(config['product_id'])
if latest:
products.append({
'name': latest['product_name'],
'price': latest['price'],
'status': latest['stock_status'],
'time': latest['captured_at']
})
return render_template('index.html', products=products)
@app.route('/api/history/<product_id>')
def api_history(product_id):
"""API:获取历史数据(供图表使用)"""
history = storage.get_price_history(product_id, days=30)
# 格式化为ECharts需要的格式
dates = [item['captured_at'][:10] for item in history]
prices = [item['price'] for item in history]
return jsonify({
'dates': dates,
'prices': prices
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
启动Web界面:
bash
python web_app.py
# 访问 http://localhost:5000
1️⃣4️⃣ 总结与延伸阅读
我们完成了什么?
通过这个完整的价格监控项目,我们实现了:
✅ 自动化监控系统 :基于APScheduler的定时任务,每天自动采集价格数据
✅ 时序数据管理 :SQLite存储历史记录,支持高效查询和统计分析
✅ 智能告警机制 :价格阈值、异常波动、库存变化三重监控
✅ 数据可视化 :Matplotlib生成趋势图,直观展示价格变化
✅ 多渠道通知 :邮件+企业微信双通道,第一时间掌握降价信息
✅ 工程化架构:模块化设计,日志完善,易于维护和扩展
最关键的是,这套系统不仅仅是一个技术demo,而是真正可以长期运行的生产级应用。它帮助我节省了数百元购物支出,更重要的是,让我理解了以下核心概念:
- 定时任务调度:Cron vs Interval,如何选择合适的触发器
- 时序数据库设计:复合主键、索引优化、数据清洗
- 异常检测算法:Z-score统计方法在价格监控中的应用
- 告警策略设计:如何平衡及时性与打扰度
技术亮点总结
| 技术点 | 实现方式 | 收获 |
|---|---|---|
| 定时调度 | APScheduler | 掌握后台任务管理 |
| 数据持久化 | SQLite + WAL | 理解时序数据存储 |
| 并发控制 | ThreadPoolExecutor | 学会控制并发度 |
| 异常处理 | Retry + 日志 | 提升系统健壮性 |
| 数据分析 | Matplotlib + NumPy | 掌握数据可视化 |
| 消息通知 | SMTP + Webhook | 实现多渠道告警 |
实际应用场景
这套系统可以直接应用于:
- 个人购物助手:监控心仪商品,抓住促销时机
- 商家竞品分析:追踪竞争对手定价策略
- 数据分析项目:积累价格数据用于市场研究
- 副业机会:为他人提供价格监控服务
下一步优化方向
功能扩展:
- 支持更多平台(淘宝、拼多多、苏宁)
- 增加历史低价预测(机器学习)
- 开发移动端App(React Native)
- 接入语音助手(小爱同学、天猫精灵)
性能优化:
- 迁移到PostgreSQL(支持更高并发)
- 使用Redis缓存热数据
- 引入消息队列(Celery + RabbitMQ)
- Docker容器化部署
智能化升级:
- 基于用户画像的个性化推荐
- 价格预测模型(LSTM时间序列)
- 自动下单(配合浏览器自动化)
延伸阅读推荐
定时任务:
- APScheduler官方文档
- 《Python自动化运维》斯)
数据分析:
- 《Python数据分析实战》(Wes McKinney)
- Pandas时间序列处理
系统设计:
- 《Designing Data-Intensive Applications》(Martin Kleppmann)
- The Twelve-Factor App
价格监控案例:
- CamelCamelCamel - 亚马逊价格追踪
- Keepa - 商业级价格监控服务
最后的话:
价格监控看似简单,实则涉及爬虫、调度、存储、告警、分析等多个技术领域。通过这个项目,我最大的感悟是:工程能力不在于写了多少行代码,而在于能否把技术组合成可靠的系统。
这套代码我已经稳定运行了6个月,帮我抓住了iPhone的618低价、机械键盘的秒杀活动,省下的钱早已超过开发这套系统的时间成本。更重要的是,它让我养成了数据驱动决策的习惯------不再冲动消费,而是基于历史价格趋势理性购物。
希望这篇教程不仅教会你写代码,更能启发你用技术解决生活中的实际问题。如果本文对你有帮助,欢迎Star、Fork或分享给更多人!有任何问题随时交流,祝编码愉快!🎉
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

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

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