![image]()
# -*- coding: utf-8 -*-
"""
weather_config.py
配置文件模块
存储所有全局配置信息:API密钥、邮箱设置、文件路径、阈值参数等。
本模块独立封装,方便修改而无需改动主程序代码。
"""
import os
# ============================================
# 和风天气 API 配置
# ============================================
# 和风天气开发者官网: https://dev.qweather.com/
# 使用前需注册账号并创建应用,获取免费版 API Key。
# 免费版支持:未来 3 天预报、实时天气、空气质量(AQI)等数据。
QWEATHER_API_KEY = "a4557fb819e4471e9b03f41adfbf26b6"
QWEATHER_API_HOST = "m9564w9rmc.re.qweatherapi.com"
# 和风天气各接口地址(使用专属 API Host,2026 年起不再使用公共 Host)
QWEATHER_CITY_LOOKUP_URL = f"https://{QWEATHER_API_HOST}/geo/v2/city/lookup"
QWEATHER_HOURLY_URL = f"https://{QWEATHER_API_HOST}/v7/weather/24h"
QWEATHER_AIR_URL = f"https://{QWEATHER_API_HOST}/v7/air/now"
# 目标城市(可修改为你所在的城市)
TARGET_CITY = "北京"
TARGET_CITY_ID = "101010100" # 北京的城市 Location ID,可从和风天气官网查询获取
# ============================================
# 邮件 SMTP 配置
# ============================================
# 以 QQ 邮箱为例,SMTP 服务器地址:smtp.qq.com,端口 465(SSL)或 587(TLS)
# 需要在邮箱设置中开启 SMTP 服务并获取授权码(不是邮箱登录密码!)
SMTP_SERVER = "smtp.qq.com"
SMTP_PORT = 465 # SSL 端口(与 SMTP_SSL 配合使用)
SMTP_USER = "3862799071@qq.com" # 发送方邮箱地址
SMTP_PASSWORD = "bkstwtcekkksccbj" # 邮箱授权码(不是登录密码)
# 接收方邮箱列表(可配置多个接收人,用逗号分隔)
RECEIVER_EMAILS = "3528753906@qq.com"
# 邮件主题模板
EMAIL_SUBJECT_TEMPLATE = "【跑操提醒】{date} 早晨跑操通知"
# ============================================
# 文件与路径配置
# ============================================
# 项目根目录(所有数据文件、日志文件、图表文件均存放于此)
BASE_DIR = r"D:\Python_Program\WeatherReport"
# 各子目录路径
DATA_DIR = os.path.join(BASE_DIR, "data") # 历史天气数据 CSV 目录
CHART_DIR = os.path.join(BASE_DIR, "charts") # 温度折线图保存目录
LOG_DIR = os.path.join(BASE_DIR, "logs") # 日志文件目录
# 确保目录存在(程序启动时自动创建)
for d in (DATA_DIR, CHART_DIR, LOG_DIR):
os.makedirs(d, exist_ok=True)
# 历史数据 CSV 文件路径(追加写入,每行一条记录)
HISTORY_CSV_PATH = os.path.join(DATA_DIR, "weather_history.csv")
# 温度折线图保存路径(文件名含日期,便于归档)
CHART_PATH_TEMPLATE = os.path.join(CHART_DIR, "temperature_{date}.png")
# 程序运行日志文件路径
LOG_PATH = os.path.join(LOG_DIR, "runner.log")
# ============================================
# 出操判断阈值配置
# ============================================
# 以下阈值用于自动判断次日早晨 6:00 是否适合出操。
# 只要满足任一条件,即判定为“不出操”。
# 极端低温阈值(摄氏度):低于此温度不出操
TEMPERATURE_MIN = 0
# 极端高温阈值(摄氏度):高于此温度不出操
TEMPERATURE_MAX = 35
# AQI(空气质量指数)阈值:大于此值不出操
AQI_MAX = 100
# 大风阈值(风力等级,和风天气返回 1-17 级):大于此值不出操
WIND_LEVEL_MAX = 6
# 风速阈值(公里/小时):大于此值不出操
WIND_SPEED_MAX = 30
# 判定为“雨雪”的天气关键词列表(包含任一关键词即不出操)
# 和风天气的 weather text 字段会返回类似 "小雨"、"大雪"、"雷阵雨" 等文本
RAIN_SNOW_KEYWORDS = [
"雨", "雪", "雾", "霾", "沙尘", "冰雹", "冻雨", "霜"
]
# 早晨跑操时间(用于筛选 API 返回的逐小时数据中的目标时间点)
RUN_TIME_HOUR = 6 # 早上 6 点
# ============================================
# UI 字体配置(使用微软雅黑)
# ============================================
UI_FONT_FAMILY = "微软雅黑"
UI_FONT_SIZE = 10
UI_FONT_TITLE = (UI_FONT_FAMILY, 14, "bold")
UI_FONT_LABEL = (UI_FONT_FAMILY, 11)
UI_FONT_BUTTON = (UI_FONT_FAMILY, 10)
UI_FONT_RESULT = (UI_FONT_FAMILY, 12, "bold")
# ============================================
# 定时任务配置
# ============================================
# 每天自动检查的时间(24小时制,格式 HH:MM)
SCHEDULED_TIME = "17:00"
# 定时任务轮询间隔(秒)
SCHEDULE_INTERVAL = 30
# ============================================
# 辅助函数:打印当前配置摘要(调试用)
# ============================================
def print_config_summary():
"""打印配置摘要,方便调试时确认配置已加载。"""
print("=" * 50)
print("【配置摘要】")
print(f" 目标城市: {TARGET_CITY}")
print(f" 数据目录: {DATA_DIR}")
print(f" 图表目录: {CHART_DIR}")
print(f" 日志目录: {LOG_DIR}")
print(f" 发件邮箱: {SMTP_USER}")
print(f" 收件邮箱: {RECEIVER_EMAILS}")
print(f" 定时时间: 每天 {SCHEDULED_TIME}")
print(f" 极端温度: {TEMPERATURE_MIN}°C ~ {TEMPERATURE_MAX}°C")
print(f" AQI 阈值: > {AQI_MAX}")
print(f" 风力阈值: > {WIND_LEVEL_MAX} 级")
print("=" * 50)
if __name__ == "__main__":
print_config_summary()
# -*- coding: utf-8 -*-
"""
weather_history.py
历史数据管理模块
负责:
1. 初始化 CSV 历史记录文件(含表头)。
2. 将每次获取的天气数据追加写入 CSV。
3. 读取历史数据,为温度折线图提供数据支撑。
4. 提供日志记录功能(记录程序运行状态和异常信息)。
"""
import csv
import os
import datetime
from weather_config import (
HISTORY_CSV_PATH,
LOG_PATH,
DATA_DIR,
LOG_DIR,
)
# ============================================
# 初始化 CSV 文件(含表头)
# ============================================
_CSV_HEADER = [
"date", # 日期,格式 YYYY-MM-DD
"hour", # 小时,目标跑操时间(如 6)
"temperature", # 温度(摄氏度)
"weather_text", # 天气描述(如"晴"、"小雨")
"wind_level", # 风力等级(1-17 级)
"wind_speed", # 风速(公里/小时)
"aqi", # 空气质量指数
"aqi_category", # AQI 等级(优/良/轻度污染等)
"decision", # 出操判定:"出操" 或 "不出操"
"reason", # 判定理由(多条用分号分隔)
"timestamp", # 记录写入时间
]
def ensure_data_dirs():
"""
确保数据目录和日志目录存在。
程序启动时调用,防止后续写入操作因目录不存在而报错。
"""
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(LOG_DIR, exist_ok=True)
def init_csv():
"""
初始化 CSV 文件。如果文件不存在,则创建并写入表头;
如果文件已存在,不做任何操作(避免覆盖已有数据)。
"""
ensure_data_dirs()
if not os.path.exists(HISTORY_CSV_PATH):
with open(HISTORY_CSV_PATH, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(_CSV_HEADER)
write_log(f"已创建历史数据文件: {HISTORY_CSV_PATH}")
def append_record(
date_str,
hour,
temperature,
weather_text,
wind_level,
wind_speed,
aqi,
aqi_category,
decision,
reason,
):
"""
将一条天气记录追加到 CSV 历史文件中。
参数说明:
date_str (str): 日期,如 "2026-06-17"
hour (int): 目标小时,如 6
temperature (float): 温度(摄氏度)
weather_text (str): 天气描述
wind_level (str): 风力等级
wind_speed (float): 风速(km/h)
aqi (int): AQI 数值
aqi_category (str): AQI 等级文本
decision (str): "出操" 或 "不出操"
reason (str): 判定理由描述
"""
init_csv()
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
row = [
date_str,
hour,
temperature,
weather_text,
wind_level,
wind_speed,
aqi,
aqi_category,
decision,
reason,
timestamp,
]
with open(HISTORY_CSV_PATH, "a", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(row)
write_log(f"已追加历史记录: {date_str} {decision}")
def read_all_history():
"""
读取全部历史数据,返回字典列表。每条记录为一个字典,键对应表头。
如果文件不存在,返回空列表。
返回:
list[dict]: 所有历史记录
"""
init_csv()
records = []
with open(HISTORY_CSV_PATH, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
records.append(row)
return records
def read_recent_history(days=7):
"""
读取最近 N 天的历史记录,用于绘制温度变化折线图。
按日期升序排列。
参数:
days (int): 读取最近多少天的记录,默认 7 天
返回:
list[dict]: 筛选后的记录列表
"""
all_records = read_all_history()
# 按日期字符串降序排列,取最近 days 条,再按日期升序排列
all_records_sorted = sorted(
all_records, key=lambda x: x.get("date", ""), reverse=True
)
recent = all_records_sorted[:days]
recent = sorted(recent, key=lambda x: x.get("date", ""))
return recent
def write_log(message):
"""
写入日志文件。每条日志带时间戳。
参数:
message (str): 日志内容
"""
ensure_data_dirs()
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {message}\n"
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line)
import os
import sys
import json
import time
import csv
import datetime
import smtplib
import socket
import threading
import email.mime.multipart
import email.mime.text
import email.mime.base
from email import encoders
# 第三方库
import requests
import matplotlib
matplotlib.use("Agg") # 使用非交互后端,避免在后台线程中弹出窗口
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
import schedule
import tkinter as tk # Python 标准库 GUI,Windows 11 自带,置于第三方库之后以明确分类
# 本地模块
import weather_config as cfg
from weather_history import (
append_record,
read_recent_history,
write_log,
init_csv,
ensure_data_dirs,
)
# ============================================
# 全局字体设置:使用微软雅黑,确保中文正常显示
# ============================================
plt.rcParams["font.family"] = ["Microsoft YaHei", "SimHei", "sans-serif"]
plt.rcParams["axes.unicode_minus"] = False # 解决负号显示为方块的问题
# ============================================
# 功能 1:天气数据爬取(和风天气 API)
# ============================================
class WeatherFetcher:
"""
天气数据爬取器。
封装了和风天气 API 的调用逻辑,包括:
- 获取城市 Location ID(基于城市名)
- 获取逐小时预报(未来 24 小时)
- 获取实时空气质量
"""
def __init__(self, api_key, city_id=None, city_name=None):
self.api_key = api_key
self.city_id = city_id
self.city_name = city_name
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
})
def _get(self, url, params):
"""
发送 GET 请求,并处理常见异常情况。
使用 requests.get() 直接调用,与诊断脚本保持一致,确保最稳定。
返回 JSON 数据(dict),请求失败返回 None。
"""
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36",
"X-QW-Api-Key": self.api_key,
}
response = requests.get(url, params=params, headers=headers, timeout=15)
response.raise_for_status()
data = response.json()
# 和风天气的 403 错误可能返回 {"error": {"status": 403, ...}}
if "error" in data:
write_log(f"API 返回错误: {data.get('error', {}).get('title', 'Unknown')} | "
f"{data.get('error', {}).get('detail', '')}")
return None
if data.get("code") != "200":
write_log(f"API 返回错误码: {data.get('code')} | 信息: {data.get('status')}")
return None
return data
except requests.exceptions.RequestException as e:
write_log(f"网络请求异常: {e}")
return None
def fetch_city_id(self):
"""
通过城市名查询 Location ID。
和风天气的逐小时预报接口需要 Location ID(如 101010100)。
"""
params = {
"location": self.city_name,
"key": self.api_key,
}
data = self._get(cfg.QWEATHER_CITY_LOOKUP_URL, params)
if data and data.get("location"):
self.city_id = data["location"][0]["id"]
write_log(f"已获取城市 ID: {self.city_id} ({self.city_name})")
return self.city_id
return None
def fetch_hourly_forecast(self):
"""
获取未来 24 小时逐小时预报。
返回:字典列表,每个元素包含 hour, temp, text, windScale, windSpeed 等字段。
"""
if not self.city_id:
self.fetch_city_id()
params = {
"location": self.city_id,
"key": self.api_key,
}
data = self._get(cfg.QWEATHER_HOURLY_URL, params)
if not data or "hourly" not in data:
return []
hourly_list = data["hourly"]
records = []
for item in hourly_list:
# 解析时间字符串,如 "2026-06-17T06:00+08:00"
fx_time = item.get("fxTime", "")
dt = datetime.datetime.fromisoformat(fx_time.replace("Z", "+00:00"))
records.append({
"datetime": dt,
"date": dt.strftime("%Y-%m-%d"),
"hour": dt.hour,
"temperature": float(item.get("temp", 0)),
"weather_text": item.get("text", ""),
"wind_level": item.get("windScale", "0"),
"wind_speed": float(item.get("windSpeed", 0)),
"humidity": item.get("humidity", ""),
"precip": item.get("precip", ""),
})
return records
def fetch_air_quality(self):
"""
获取当前城市的实时空气质量。
返回:字典,包含 aqi, category, pm10, pm2p5 等字段。
注意:免费版订阅可能不包含空气质量接口,返回 403 时给出友好提示。
"""
if not self.city_id:
self.fetch_city_id()
params = {
"location": self.city_id,
"key": self.api_key,
}
data = self._get(cfg.QWEATHER_AIR_URL, params)
if not data or "now" not in data:
# 免费版订阅可能不包含空气质量接口,返回占位值
return {"aqi": -1, "category": "该订阅不包含空气质量数据", "pm10": -1, "pm2p5": -1}
now = data["now"]
return {
"aqi": int(now.get("aqi", -1)),
"category": now.get("category", "未知"),
"pm10": int(now.get("pm10", -1)),
"pm2p5": int(now.get("pm2p5", -1)),
}
def get_target_hour_weather(self, target_hour=6, target_date_offset=1):
"""
从逐小时预报中提取目标日期的目标小时数据。
默认提取"明天" target_hour 点的数据(target_date_offset=1)。
参数:
target_hour (int): 目标小时(如 6 表示早上 6 点)
target_date_offset (int): 日期偏移,1 表示明天,0 表示今天
返回:
dict or None: 匹配到的天气记录,无则返回 None
"""
today = datetime.date.today()
target_date = today + datetime.timedelta(days=target_date_offset)
target_date_str = target_date.strftime("%Y-%m-%d")
hourly = self.fetch_hourly_forecast()
for rec in hourly:
if rec["date"] == target_date_str and rec["hour"] == target_hour:
return rec
return None
# ============================================
# 功能 2:出操智能判断
# ============================================
class WeatherDecision:
"""
出操判断器。
根据用户配置的温度、天气、风力、AQI 阈值,综合判断次日早晨是否适合出操。
"""
def __init__(self, weather_rec, air_rec):
"""
参数:
weather_rec (dict): WeatherFetcher.get_target_hour_weather() 返回的记录
air_rec (dict): WeatherFetcher.fetch_air_quality() 返回的空气质量记录
"""
self.weather = weather_rec
self.air = air_rec
self.reasons = [] # 收集不出操的理由
def judge(self):
"""
执行判断,返回二元组 (decision, reason)。
decision: str, "出操" 或 "不出操"
reason: str, 判定理由(可含多条,用分号分隔)
"""
if not self.weather:
return "不出操", "无法获取天气数据,请检查网络或 API 配置。"
temp = self.weather["temperature"]
text = self.weather["weather_text"]
wind_level = str(self.weather.get("wind_level", "0"))
wind_speed = self.weather.get("wind_speed", 0)
aqi = self.air.get("aqi", -1)
# 检查极端低温
if temp < cfg.TEMPERATURE_MIN:
self.reasons.append(f"温度过低({temp}°C < {cfg.TEMPERATURE_MIN}°C),易引发感冒或冻伤")
# 检查极端高温
if temp > cfg.TEMPERATURE_MAX:
self.reasons.append(f"温度过高({temp}°C > {cfg.TEMPERATURE_MAX}°C),存在中暑风险")
# 检查雨雪雾霾等恶劣天气
for keyword in cfg.RAIN_SNOW_KEYWORDS:
if keyword in text:
self.reasons.append(f"天气状况不佳({text}),不适合户外运动")
break
# 检查风力等级
try:
wl = int(wind_level.replace("级", "").split("-")[0])
except ValueError:
wl = 0
if wl > cfg.WIND_LEVEL_MAX:
self.reasons.append(f"风力过大({wind_level} > {cfg.WIND_LEVEL_MAX}级),易影响跑步安全")
# 检查风速
if wind_speed > cfg.WIND_SPEED_MAX:
self.reasons.append(f"风速过快({wind_speed}km/h > {cfg.WIND_SPEED_MAX}km/h),体感不适")
# 检查 AQI(当 aqi == -1 时,说明免费版订阅不包含空气质量接口,跳过检查)
if aqi != -1 and aqi > cfg.AQI_MAX:
self.reasons.append(f"空气质量差(AQI {aqi} > {cfg.AQI_MAX}),不利于呼吸健康")
# 综合判定
if self.reasons:
decision = "不出操"
reason = ";".join(self.reasons)
else:
aqi_display = aqi if aqi != -1 else "暂无数据"
decision = "出操"
reason = f"天气良好({text},{temp}°C,AQI {aqi_display}),请同学们及时到指定地点集合!"
return decision, reason
# ============================================
# 功能 3:温度变化折线图绘制
# ============================================
class TemperatureChart:
"""
温度折线图绘制器。
基于历史 CSV 数据,绘制最近 N 天的温度变化趋势折线图。
"""
@staticmethod
def draw(days=7, save_path=None):
"""
绘制最近 days 天的温度变化折线图,并保存为 PNG 文件。
参数:
days (int): 取最近多少天的记录,默认 7 天
save_path (str): 图片保存路径,为 None 时使用默认路径
返回:
str: 保存的图片路径,失败返回 None
"""
records = read_recent_history(days=days)
if not records:
write_log("历史数据不足,无法绘制折线图")
return None
# 提取日期和温度
dates = [r["date"] for r in records]
temps = [float(r["temperature"]) for r in records]
# 创建画布
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=150)
# 绘制折线 + 数据点标记
ax.plot(dates, temps, color="#E74C3C", linewidth=2.5, marker="o", markersize=6, zorder=3)
# 填充折线下方区域(半透明),增强视觉效果
ax.fill_between(dates, temps, alpha=0.15, color="#E74C3C")
# 在每个数据点上方标注温度数值
for i, (d, t) in enumerate(zip(dates, temps)):
ax.annotate(
f"{t:.1f}°C",
(i, t),
textcoords="offset points",
xytext=(0, 10),
ha="center",
fontsize=9,
color="#333333",
)
# 设置标题与标签(使用微软雅黑字体)
ax.set_title(f"近 {len(dates)} 天早晨 {cfg.RUN_TIME_HOUR}:00 温度变化趋势", fontsize=14, fontweight="bold", color="#2C3E50")
ax.set_xlabel("日期", fontsize=11, color="#555555")
ax.set_ylabel("温度 (°C)", fontsize=11, color="#555555")
# 美化网格线
ax.grid(True, linestyle="--", alpha=0.4, color="#999999")
ax.set_axisbelow(True)
# 旋转 x 轴标签,避免重叠
plt.xticks(rotation=30, ha="right")
# 设置背景色与边框
ax.set_facecolor("#FAFAFA")
fig.patch.set_facecolor("#FFFFFF")
for spine in ax.spines.values():
spine.set_color("#CCCCCC")
# 紧凑布局
plt.tight_layout()
# 确定保存路径
if not save_path:
today_str = datetime.date.today().strftime("%Y%m%d")
save_path = cfg.CHART_PATH_TEMPLATE.format(date=today_str)
os.makedirs(os.path.dirname(save_path), exist_ok=True)
plt.savefig(save_path, dpi=150, bbox_inches="tight")
plt.close(fig)
write_log(f"温度折线图已保存: {save_path}")
return save_path
# ============================================
# 功能 4:邮件自动发送(SMTP)
# ============================================
class MailSender:
"""
邮件发送器。
使用 SMTP 协议发送 HTML 格式邮件,支持嵌入文字、表格及附件(折线图)。
"""
def __init__(self, smtp_server, smtp_port, user, password):
self.smtp_server = smtp_server
self.smtp_port = smtp_port
self.user = user
self.password = password
def send(self, receivers, subject, html_body, attachments=None):
"""
发送邮件。若网络不可用,将邮件内容保存到本地文件。
"""
if not receivers or "@" not in receivers:
write_log("收件人邮箱未配置,跳过邮件发送")
return False
try:
msg = email.mime.multipart.MIMEMultipart()
msg["From"] = self.user
msg["To"] = receivers
msg["Subject"] = subject
msg.attach(email.mime.text.MIMEText(html_body, "html", "utf-8"))
if attachments:
for filepath in attachments:
if os.path.exists(filepath):
with open(filepath, "rb") as f:
part = email.mime.base.MIMEBase("application", "octet-stream")
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f"attachment; filename=\"{os.path.basename(filepath)}\"",
)
msg.attach(part)
with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, timeout=15) as server:
server.login(self.user, self.password)
server.sendmail(self.user, receivers.split(","), msg.as_string())
write_log(f"邮件已发送至: {receivers}")
return True
except (socket.gaierror, OSError) as e:
# DNS 解析失败或网络不通,保存到本地文件
write_log(f"网络不可用,邮件发送失败: {e}")
self._save_email_to_local(subject, html_body, attachments)
return False
except smtplib.SMTPAuthenticationError as e:
write_log(f"SMTP 认证失败,请检查邮箱授权码: {e}")
except smtplib.SMTPException as e:
write_log(f"SMTP 发送异常: {e}")
except Exception as e:
write_log(f"邮件发送失败(未知错误): {e}")
return False
def _save_email_to_local(self, subject, html_body, attachments=None):
"""
当网络不可用时,将邮件内容保存到本地 HTML 文件,便于查看和手动发送。
"""
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(cfg.DATA_DIR, f"email_{timestamp}.html")
full_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{subject}</title>
</head>
<body>
{html_body}
<hr>
<p style="color:#999;font-size:12px;">
【邮件备份】由于网络原因未能发送,保存于 {timestamp}<br>
发件人: {self.user}<br>
附件: {attachments if attachments else "无"}
</p>
</body>
</html>"""
with open(filename, "w", encoding="utf-8") as f:
f.write(full_html)
write_log(f"邮件已保存到本地: {filename}")
# ============================================
# 功能 5:UI 界面(tkinter)
# ============================================
class WeatherApp:
"""
主应用 UI 类。
使用 tkinter 构建图形界面,包含:
- 城市、API Key、邮箱设置输入框
- 手动触发按钮(立即获取并分析)
- 结果显示区域(文本 + 颜色标识)
- 定时任务状态提示
- 历史数据查看与图表预览
"""
def __init__(self, root):
self.root = root
self.root.title("天气跑操提醒助手")
self.root.geometry("720x580")
self.root.configure(bg="#F5F6FA")
self.root.resizable(False, False)
# 居中窗口
self.center_window()
# 初始化各模块实例
self.fetcher = WeatherFetcher(cfg.QWEATHER_API_KEY, cfg.TARGET_CITY_ID, cfg.TARGET_CITY)
self.mailer = MailSender(cfg.SMTP_SERVER, cfg.SMTP_PORT, cfg.SMTP_USER, cfg.SMTP_PASSWORD)
# 构建 UI 界面
self._build_ui()
# 启动后台定时线程
self._start_scheduler_thread()
def center_window(self):
"""将窗口居中显示于屏幕。"""
self.root.update_idletasks()
width, height = 720, 580
x = (self.root.winfo_screenwidth() // 2) - (width // 2)
y = (self.root.winfo_screenheight() // 2) - (height // 2)
self.root.geometry(f"{width}x{height}+{x}+{y}")
def _build_ui(self):
"""构建 tkinter 界面组件。"""
font_title = cfg.UI_FONT_TITLE
font_label = cfg.UI_FONT_LABEL
font_btn = cfg.UI_FONT_BUTTON
font_result = cfg.UI_FONT_RESULT
# 顶部标题栏
title_frame = tk.Frame(self.root, bg="#2C3E50", height=50)
title_frame.pack(fill=tk.X)
title_frame.pack_propagate(False)
tk.Label(
title_frame,
text="☀ 天气跑操提醒助手",
font=font_title,
bg="#2C3E50",
fg="#FFFFFF",
).pack(side=tk.LEFT, padx=15, pady=8)
# 主内容区域
main_frame = tk.Frame(self.root, bg="#F5F6FA")
main_frame.pack(fill=tk.BOTH, expand=True, padx=15, pady=10)
# --- 配置信息区 ---
cfg_frame = tk.LabelFrame(main_frame, text="配置信息", font=font_label, bg="#FFFFFF", fg="#333333", padx=10, pady=8)
cfg_frame.pack(fill=tk.X, pady=5)
tk.Label(cfg_frame, text="目标城市:", font=font_label, bg="#FFFFFF").grid(row=0, column=0, sticky=tk.W, pady=3)
self.entry_city = tk.Entry(cfg_frame, width=20, font=font_label)
self.entry_city.grid(row=0, column=1, sticky=tk.W, pady=3, padx=5)
self.entry_city.insert(0, cfg.TARGET_CITY)
tk.Label(cfg_frame, text="API Key:", font=font_label, bg="#FFFFFF").grid(row=0, column=2, sticky=tk.W, pady=3, padx=10)
self.entry_key = tk.Entry(cfg_frame, width=30, font=font_label, show="*")
self.entry_key.grid(row=0, column=3, sticky=tk.W, pady=3, padx=5)
self.entry_key.insert(0, cfg.QWEATHER_API_KEY)
tk.Label(cfg_frame, text="发件邮箱:", font=font_label, bg="#FFFFFF").grid(row=1, column=0, sticky=tk.W, pady=3)
self.entry_sender = tk.Entry(cfg_frame, width=20, font=font_label)
self.entry_sender.grid(row=1, column=1, sticky=tk.W, pady=3, padx=5)
self.entry_sender.insert(0, cfg.SMTP_USER)
tk.Label(cfg_frame, text="收件邮箱:", font=font_label, bg="#FFFFFF").grid(row=1, column=2, sticky=tk.W, pady=3, padx=10)
self.entry_receiver = tk.Entry(cfg_frame, width=30, font=font_label)
self.entry_receiver.grid(row=1, column=3, sticky=tk.W, pady=3, padx=5)
self.entry_receiver.insert(0, cfg.RECEIVER_EMAILS)
# --- 操作按钮区 ---
btn_frame = tk.Frame(main_frame, bg="#F5F6FA")
btn_frame.pack(fill=tk.X, pady=8)
self.btn_run = tk.Button(
btn_frame,
text="立即检查明天天气",
font=font_btn,
bg="#3498DB",
fg="#FFFFFF",
activebackground="#2980B9",
width=18,
height=2,
cursor="hand2",
command=self.on_run_now,
)
self.btn_run.pack(side=tk.LEFT, padx=5)
self.btn_chart = tk.Button(
btn_frame,
text="查看温度折线图",
font=font_btn,
bg="#2ECC71",
fg="#FFFFFF",
activebackground="#27AE60",
width=18,
height=2,
cursor="hand2",
command=self.on_show_chart,
)
self.btn_chart.pack(side=tk.LEFT, padx=5)
self.btn_history = tk.Button(
btn_frame,
text="查看历史数据",
font=font_btn,
bg="#9B59B6",
fg="#FFFFFF",
activebackground="#8E44AD",
width=18,
height=2,
cursor="hand2",
command=self.on_show_history,
)
self.btn_history.pack(side=tk.LEFT, padx=5)
# --- 状态提示区 ---
self.status_var = tk.StringVar(value="定时任务已启动:每天 17:00 自动检查")
status_label = tk.Label(
main_frame,
textvariable=self.status_var,
font=(cfg.UI_FONT_FAMILY, 9),
bg="#F5F6FA",
fg="#666666",
)
status_label.pack(anchor=tk.W, pady=5)
# --- 结果显示区 ---
result_frame = tk.LabelFrame(main_frame, text="检查结果", font=font_label, bg="#FFFFFF", fg="#333333", padx=10, pady=8)
result_frame.pack(fill=tk.BOTH, expand=True, pady=5)
self.result_text = tk.Text(
result_frame,
font=font_result,
bg="#FFFFFF",
fg="#333333",
wrap=tk.WORD,
height=14,
state=tk.DISABLED,
)
self.result_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
scrollbar = tk.Scrollbar(result_frame, command=self.result_text.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.result_text.config(yscrollcommand=scrollbar.set)
def _start_scheduler_thread(self):
"""启动后台线程,运行 schedule 定时任务。"""
def scheduler_loop():
# 每天 17:00 执行一次自动检查
schedule.every().day.at(cfg.SCHEDULED_TIME).do(self._scheduled_task)
write_log(f"定时任务已注册:每天 {cfg.SCHEDULED_TIME} 执行")
while True:
schedule.run_pending()
time.sleep(cfg.SCHEDULE_INTERVAL)
t = threading.Thread(target=scheduler_loop, daemon=True)
t.start()
def _scheduled_task(self):
"""定时任务回调函数:每天 17:00 自动执行。"""
write_log("定时任务触发:开始自动检查天气...")
self.status_var.set(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 正在执行定时检查...")
try:
self._execute_check(send_email=True)
self.status_var.set(f"定时检查完成:{datetime.datetime.now().strftime('%H:%M:%S')}")
except Exception as e:
write_log(f"定时任务异常: {e}")
self.status_var.set(f"定时检查失败: {e}")
def on_run_now(self):
"""点击【立即检查】按钮时的回调。"""
self.btn_run.config(state=tk.DISABLED, text="检查中...")
self.root.update()
try:
self._execute_check(send_email=True)
except Exception as e:
self._update_result(f"运行出错:{e}", error=True)
write_log(f"手动运行异常: {e}")
finally:
self.btn_run.config(state=tk.NORMAL, text="立即检查明天天气")
def _execute_check(self, send_email=False):
"""
执行核心检查逻辑:
1. 更新配置(从 UI 输入框读取最新值)
2. 获取天气数据与空气质量
3. 出操判断
4. 保存历史数据
5. 绘制温度折线图
6. 发送邮件(若 send_email=True)
"""
# 1. 更新配置
city = self.entry_city.get().strip() or cfg.TARGET_CITY
key = self.entry_key.get().strip() or cfg.QWEATHER_API_KEY
sender = self.entry_sender.get().strip() or cfg.SMTP_USER
receiver = self.entry_receiver.get().strip() or cfg.RECEIVER_EMAILS
self.fetcher.city_name = city
self.fetcher.api_key = key
self.mailer.user = sender
self.mailer.password = cfg.SMTP_PASSWORD # 授权码从配置文件读取,UI 不显示
# 2. 获取天气数据
target_hour = cfg.RUN_TIME_HOUR
weather_rec = self.fetcher.get_target_hour_weather(target_hour=target_hour, target_date_offset=1)
air_rec = self.fetcher.fetch_air_quality()
# 3. 出操判断
decider = WeatherDecision(weather_rec, air_rec)
decision, reason = decider.judge()
# 4. 保存历史数据
if weather_rec:
append_record(
date_str=weather_rec["date"],
hour=weather_rec["hour"],
temperature=weather_rec["temperature"],
weather_text=weather_rec["weather_text"],
wind_level=weather_rec.get("wind_level", "0"),
wind_speed=weather_rec.get("wind_speed", 0),
aqi=air_rec.get("aqi", -1),
aqi_category=air_rec.get("category", "未知"),
decision=decision,
reason=reason,
)
# 5. 绘制温度折线图
chart_path = TemperatureChart.draw()
# 6. 组装结果文本
if weather_rec:
aqi_val = air_rec.get('aqi', '未知')
aqi_cat = air_rec.get('category', '未知')
if aqi_val == -1:
aqi_display = "暂无数据(免费版订阅不包含空气质量接口)"
aqi_cat_display = "—"
else:
aqi_display = f"{aqi_val}"
aqi_cat_display = aqi_cat
result_lines = [
f"【查询日期】{weather_rec['date']} {target_hour}:00",
f"【天气状况】{weather_rec['weather_text']}",
f"【温度】{weather_rec['temperature']} °C",
f"【风力】{weather_rec.get('wind_level', '0')} 级 / 风速 {weather_rec.get('wind_speed', 0)} km/h",
f"【AQI】{aqi_display} ({aqi_cat_display})",
"",
f"【判定结果】{decision}",
f"【详细理由】{reason}",
]
else:
result_lines = [
"【查询失败】无法获取天气数据。",
"请检查以下事项:",
" 1. 网络连接是否正常;",
" 2. API Key 是否正确(和风天气开发者平台申请);",
" 3. 目标城市名称是否填写正确。",
]
result_text = "\n".join(result_lines)
self._update_result(result_text, error=(decision == "不出操" or not weather_rec))
# 7. 发送邮件(若需要)
if send_email and weather_rec and receiver:
subject = cfg.EMAIL_SUBJECT_TEMPLATE.format(date=weather_rec["date"])
html_body = self._build_email_html(weather_rec, air_rec, decision, reason)
attachments = [chart_path] if chart_path else None
self.mailer.send(receiver, subject, html_body, attachments)
def _build_email_html(self, weather_rec, air_rec, decision, reason):
"""
构建 HTML 格式的邮件正文,包含表格和样式,美观清晰。
"""
aqi_val = air_rec.get('aqi', '未知')
aqi_cat = air_rec.get('category', '未知')
if aqi_val == -1:
aqi_display = "暂无数据"
aqi_cat_display = "免费版不包含空气质量接口"
else:
aqi_display = f"{aqi_val}"
aqi_cat_display = aqi_cat
color = "#27AE60" if decision == "出操" else "#E74C3C"
card = f"""
<div style="max-width:600px;margin:20px auto;padding:20px;border:1px solid #ddd;border-radius:8px;font-family:Microsoft YaHei,sans-serif;">
<h2 style="color:#2C3E50;text-align:center;">🌤 明日跑操提醒</h2>
<table style="width:100%;border-collapse:collapse;margin:15px 0;">
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">日期</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec['date']} {cfg.RUN_TIME_HOUR}:00</td></tr>
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">天气</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec['weather_text']}</td></tr>
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">温度</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec['temperature']} °C</td></tr>
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">风力</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec.get('wind_level', '0')} 级</td></tr>
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">AQI</td><td style="padding:8px;border-bottom:1px solid #eee;">{aqi_display} ({aqi_cat_display})</td></tr>
</table>
<div style="text-align:center;padding:15px;background-color:{color};color:#fff;border-radius:6px;font-size:18px;font-weight:bold;">
{decision}
</div>
<p style="margin-top:15px;color:#555;font-size:14px;line-height:1.6;">
<strong>判定理由:</strong>{reason}
</p>
<hr style="border:none;border-top:1px solid #eee;margin:15px 0;">
<p style="font-size:12px;color:#999;text-align:center;">
本邮件由 天气跑操提醒助手 自动发送<br>
发送时间:{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
</p>
</div>
"""
return card
def _update_result(self, text, error=False):
"""
更新结果显示区的文本,并根据是否出错改变文字颜色。
"""
self.result_text.config(state=tk.NORMAL)
self.result_text.delete("1.0", tk.END)
color = "#E74C3C" if error else "#2C3E50"
self.result_text.config(fg=color)
self.result_text.insert(tk.END, text)
self.result_text.config(state=tk.DISABLED)
def on_show_chart(self):
"""点击【查看温度折线图】按钮:打开图表文件夹。"""
chart_path = TemperatureChart.draw()
if chart_path:
self._update_result(f"温度折线图已生成,保存路径:\n{chart_path}\n\n正在打开图片...")
# 使用系统默认程序打开图片
os.startfile(chart_path)
else:
self._update_result("暂无足够历史数据,请先运行一次天气检查。", error=True)
def on_show_history(self):
"""点击【查看历史数据】按钮:在结果区展示最近 10 条记录。"""
records = read_recent_history(days=30)
if not records:
self._update_result("暂无历史数据。", error=True)
return
lines = ["【最近历史记录】\n"]
for r in records[-10:]:
aqi_hist = r['aqi']
aqi_hist_display = "暂无" if aqi_hist == "-1" or aqi_hist == -1 else f"AQI {aqi_hist}"
lines.append(
f"{r['date']} {r['hour']}:00 | "
f"{r['weather_text']} {r['temperature']}°C | "
f"{aqi_hist_display} | "
f"判定:{r['decision']}"
)
self._update_result("\n".join(lines))
# ============================================
# 程序入口
# ============================================
if __name__ == "__main__":
# 确保目录存在
ensure_data_dirs()
init_csv()
write_log("=" * 40)
write_log("程序启动")
cfg.print_config_summary()
# 检查 tkinter 可用性(已在顶部导入,此处验证环境完整性)
try:
tk._test() # 利用 tkinter 模块存在性确认环境可用
except AttributeError:
pass # 正常情况,无需处理
except Exception as e:
print(f"错误:tkinter 环境异常: {e}")
print("建议:在 Windows 上请使用标准安装包安装 Python(勾选 tcl/tk 选项)。")
sys.exit(1)
# 启动主窗口
root = tk.Tk()
app = WeatherApp(root)
root.mainloop()