spider_weather.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. # 【数据准备 C:独立天气采集脚本】给数据库天气查询分支准备数据,不由网页每次查询自动触发。
  2. # C1 读取配置 → C2 进入 __main__ 建表 → C3 执行一次 update_weather
  3. # → C4 setup_scheduler 注册任务并持续检查是否到时。
  4. # update_weather 内部:连库 → 遍历城市 → 查更新时间 → 判断是否过期
  5. # → 按需请求天气 API → 逐日写库 → 关闭连接。
  6. """
  7. 需求:实现天气数据爬虫,定期从第三方API获取天气数据并存储到MySQL数据库
  8. 思路步骤:
  9. 1. 导入必要的模块和库
  10. 2. 配置API密钥、城市代码和数据库连接参数
  11. 3. 实现fetch_weather_data函数(从API获取天气数据)
  12. 4. 实现get_latest_update_time函数(获取数据库中最新更新时间)
  13. 5. 实现should_update_data函数(判断是否需要更新数据)
  14. 6. 实现store_weather_data函数(将天气数据存储到数据库)
  15. 7. 实现update_weather函数(更新指定城市的天气数据)
  16. 8. 实现setup_scheduler函数(设置定时任务)
  17. 9. 主函数(创建表、执行一次更新、启动定时任务)
  18. """
  19. import gzip
  20. import json
  21. import os
  22. import sys
  23. import time
  24. from datetime import datetime
  25. import mysql.connector
  26. import pytz
  27. import requests
  28. import schedule
  29. sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
  30. from config import Config, env
  31. # 【数据准备 C1】读取配置并解析时区;这属于模块顶层代码,导入时也会执行。
  32. cfg = Config()
  33. # 把配置中的时区名称转换为 pytz 时区对象;只有库识别的名称才能成功。
  34. # 这是模块加载阶段执行的语句,名称无效会在进入下方 __main__ 之前抛异常。
  35. TZ = pytz.timezone(cfg.weather_timezone)
  36. def get_db_config():
  37. # 【连接内部步骤 1】组装 mysql.connector.connect 所需的关键字参数。
  38. # 由 connect_db 和主入口共用,统一数据库连接参数;charset 指定连接使用的字符集。
  39. # 返回字典不会连接数据库,连接动作要等 mysql.connector.connect(**字典)。
  40. return {
  41. "host": cfg.host,
  42. "user": cfg.user,
  43. "password": cfg.password,
  44. "database": cfg.database,
  45. "charset": "utf8mb4",
  46. }
  47. def connect_db():
  48. # 【连接内部步骤 2】** 把字典展开成 host=... 等参数,返回数据库连接。
  49. # 返回 MySQL 连接对象,调用方再通过 conn.cursor 创建执行 SQL 的游标。
  50. # 本函数没有捕获连接异常,失败会交给调用方处理,不会返回一个伪造的空连接。
  51. return mysql.connector.connect(**get_db_config())
  52. def fetch_weather_data(city, location):
  53. """
  54. 从第三方API拿取天气数据。
  55. :param city:
  56. :param location:
  57. :return: 天气数据
  58. """
  59. # 【更新内部步骤 4.1】用配置的城市代码请求天气;timeout=10 限制本次网络等待。
  60. # 输入 city 用于日志说明,location 是天气服务的城市代码;二者不能互相替代。
  61. # 成功时返回天气 JSON 对应的字典;已捕获的请求/解析异常通常返回 None,供调用方跳过写入。
  62. headers = {"X-QW-Api-Key": cfg.weather_api_key, "Accept-Encoding": "gzip"}
  63. url = f"{cfg.weather_base_url}?location={location}"
  64. try:
  65. response = requests.get(url, headers=headers, timeout=10)
  66. print(response)
  67. # <Response [200]>
  68. # 检查 HTTP 是否返回失败状态,失败则抛请求异常,交给下方 requests.RequestException 分支。
  69. # HTTP 成功不等于天气接口的业务 code 成功,业务 code 在 store_weather_data 里另外判断。
  70. response.raise_for_status()
  71. print(222222222, response.raise_for_status())
  72. # 222222222 None
  73. print(11111111, response.headers.get("Content-Encoding"))
  74. # 11111111 gzip
  75. # 【更新内部步骤 4.2】解析返回体。requests 可能已经解压响应,重复解压失败时走 BadGzipFile 回退。
  76. if response.headers.get("Content-Encoding") == "gzip":
  77. print("gzip")
  78. # gzip
  79. # 使用.decompress方法时候报错了
  80. data = gzip.decompress(response.content).decode("utf-8")
  81. print(1111, data)
  82. else:
  83. data = response.text
  84. # exit()
  85. # 把解码后的 JSON 文本转成字典;字典中的 daily 是后续逐日入库的原始数据。
  86. return json.loads(data)
  87. except requests.RequestException as e:
  88. print(f"请求 {city} 天气数据失败: {e}")
  89. return None
  90. except json.JSONDecodeError as e:
  91. print(f"{city} JSON 解析错误: {e}, 响应内容: {response.text[:500]}...")
  92. return None
  93. except gzip.BadGzipFile:
  94. print(f"{city} 数据未正确解压,尝试直接解析: {response.text[:500]}...")
  95. return json.loads(response.text) if response.text else None
  96. def get_latest_update_time(cursor, city):
  97. """
  98. 拿取 这个 城市city 在数据表weather_data中最新更新时间。
  99. :param cursor:
  100. :param city:
  101. :return: datetime对象 或 None
  102. fetchone() 返回单个元组,元素顺序与 SELECT 列顺序一致:
  103. # cursor.execute("SELECT city, MAX(update_time) FROM weather_data WHERE city = %s", (city,))
  104. # result = cursor.fetchone()
  105. # result => ("北京", datetime.datetime(2026, 4, 28, 10, 30, 0))
  106. # result[0] => "北京"
  107. # result[1] => datetime.datetime(2026, 4, 28, 10, 30, 0)
  108. # 如果查询无结果(表中无该城市数据):
  109. # result => (None,)
  110. fetchall() 返回元组列表,适用于多行结果:
  111. # cursor.execute("SELECT city, update_time FROM weather_data WHERE city = %s", (city,))
  112. # results = cursor.fetchall()
  113. # results => [
  114. # ("北京", datetime.datetime(2026, 4, 27, 10, 30, 0)),
  115. # ("北京", datetime.datetime(2026, 4, 28, 10, 30, 0))
  116. # ]
  117. """
  118. # 【更新内部步骤 2】只读该城市最新 update_time,供下一步判断是否需要更新。
  119. # 输入 cursor 是已有游标,city 用于过滤该城市;输出最近更新时间或 None。
  120. # MAX 是 SQL 聚合,所以正常查询只得到一行一个值,用 fetchone 而不是 fetchall。
  121. cursor.execute("SELECT MAX(update_time) FROM weather_data WHERE city = %s", (city,))
  122. # 结果形如 (时间值,);即使该城市没有记录,MAX 通常仍得到一行 (None,)。
  123. # 下方读取 result[0],把没有可用时间的情况统一返回 None。
  124. result = cursor.fetchone()
  125. # 本查询只返回一个聚合值,result 示例 => (datetime.datetime(2026, 4, 28, 10, 30, 0),)
  126. # 若无数据 => (None,)
  127. return result[0] if result[0] else None
  128. def should_update_data(latest_time, force_update=False):
  129. """
  130. 如果没有更新时间 或者 更新时间大于24小时 或者 force_update=True,就返回True,就去更新数据库中的这个城市的天气数据。
  131. 其他情况返回False,表示不更新数据库中的数据。
  132. :param latest_time: 某个城市最新的更新时间
  133. :param force_update: 默认否
  134. :return: 是 或这 否 true or false
  135. """
  136. # 【更新内部步骤 3】强制更新或没有记录时直接返回 True,否则检查是否已满 24 小时。
  137. # 调用者 update_weather 为每个城市调用一次。force_update=True 强制返回 True;否则看最近更新时间。
  138. # 返回布尔值只是更新决定,本函数不发 HTTP 请求,也不写入天气记录。
  139. if force_update:
  140. return True
  141. if not latest_time:
  142. return True
  143. current_time = datetime.now(TZ)
  144. # 数据库的无时区时间按本地时区解释;已有时区的时间转换到同一时区再比较。
  145. latest_time = TZ.localize(latest_time) if latest_time.tzinfo is None else latest_time.astimezone(TZ)
  146. # 时间差先换算成秒,再除以 3600 得到小时;满 24 小时才返回 True,不是每次启动都强制更新。
  147. return (current_time - latest_time).total_seconds() / 3600 >= 24
  148. def store_weather_data(conn, cursor, city, data):
  149. """
  150. 把从第三方拿到的天气数据(json)解析出来,组装成可以插入到mysql天气表中的insert语句。然后执行插入动作。
  151. :param conn:
  152. :param cursor:
  153. :param city:
  154. :param data:
  155. :return:
  156. """
  157. # {"code":"200",
  158. # "updateTime":"2026-02-04T10:42+08:00",
  159. # "fxLink":"https://www.qweather.com/weather/beijing-101010100.html",
  160. # "daily":[
  161. # {"fxDate":"2026-02-04","sunrise":"07:21","sunset":"17:37","moonrise":"20:21",
  162. # "moonset":"08:41","moonPhase":"亏凸月","moonPhaseIcon":"805","tempMax":"12",
  163. # "tempMin":"-2","iconDay":"100","textDay":"晴","iconNight":"150","textNight":"晴",
  164. # "wind360Day":"225","windDirDay":"西南风","windScaleDay":"1-3","windSpeedDay":"3",
  165. # "wind360Night":"0","windDirNight":"北风","windScaleNight":"1-3","windSpeedNight":"16",
  166. # "humidity":"29","precip":"0.0","pressure":"1016","vis":"25","cloud":"0","uvIndex":"3"}
  167. # 。。。。
  168. # "refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}
  169. # 【更新内部步骤 5.1】先检查接口业务码;无效数据提前 return,不执行写库。
  170. # 输入 conn 用于提交/回滚,cursor 用于执行 SQL,city 标识写入城市,data 是接口返回字典。
  171. # 没有直接返回成功列表;通过逐条 SQL 写入和日志体现结果,由 update_weather 继续下一城市。
  172. # or 有短路行为:data 为空时不再调用 data.get,直接进入跳过分支。
  173. # 接口 code 在这里按字符串 "200" 比较,与 HTTP status_code 的整数 200 是不同字段。
  174. if not data or data.get("code") != "200":
  175. print(f"{city} 数据无效,跳过存储。")
  176. return
  177. # 取逐日预报列表;没有 daily 就得到空列表,下面 for 不执行,不会写入任何天气行。
  178. daily_data = data.get("daily", [])
  179. update_time = datetime.fromisoformat(
  180. data.get("updateTime").replace("+08:00", "+08:00")
  181. ).astimezone(TZ).replace(tzinfo=None)
  182. # 【更新内部步骤 5.2】按 daily 中的每一天组装字段,通过城市+日期的唯一键插入或更新。
  183. for day in daily_data:
  184. # {"fxDate":"2026-02-04","sunrise":"07:21","sunset":"17:37","moonrise":"20:21",
  185. # # "moonset":"08:41","moonPhase":"亏凸月","moonPhaseIcon":"805","tempMax":"12",
  186. # # "tempMin":"-2","iconDay":"100","textDay":"晴","iconNight":"150","textNight":"晴",
  187. # # "wind360Day":"225","windDirDay":"西南风","windScaleDay":"1-3","windSpeedDay":"3",
  188. # # "wind360Night":"0","windDirNight":"北风","windScaleNight":"1-3","windSpeedNight":"16",
  189. # # "humidity":"29","precip":"0.0","pressure":"1016","vis":"25","cloud":"0","uvIndex":"3"}
  190. fx_date = datetime.strptime(day["fxDate"], "%Y-%m-%d").date()
  191. # 元组中的值按后面 INSERT 列的顺序排列;参数顺序与占位符数量都要对应。
  192. # SQL 字符串本身包含 %s,占位符的实际值由 cursor.execute 的第二个参数提供。
  193. values = (
  194. city,
  195. fx_date,
  196. day.get("sunrise"),
  197. day.get("sunset"),
  198. day.get("moonrise"),
  199. day.get("moonset"),
  200. day.get("moonPhase"),
  201. day.get("moonPhaseIcon"),
  202. day.get("tempMax"),
  203. day.get("tempMin"),
  204. day.get("iconDay"),
  205. day.get("textDay"),
  206. day.get("iconNight"),
  207. day.get("textNight"),
  208. day.get("wind360Day"),
  209. day.get("windDirDay"),
  210. day.get("windScaleDay"),
  211. day.get("windSpeedDay"),
  212. day.get("wind360Night"),
  213. day.get("windDirNight"),
  214. day.get("windScaleNight"),
  215. day.get("windSpeedNight"),
  216. day.get("precip"),
  217. day.get("uvIndex"),
  218. day.get("humidity"),
  219. day.get("pressure"),
  220. day.get("vis"),
  221. day.get("cloud"),
  222. update_time,
  223. )
  224. # upsert (Update + Insert): 插入或更新操作,一条SQL同时处理两种情况
  225. # 原理:依赖表的唯一键约束(本表中为 UNIQUE KEY unique_city_date(city, fx_date))
  226. # - INSERT 路径:表中不存在该 (city, fx_date) 组合 → 直接插入新行
  227. # - UPDATE 路径:表中已存在该 (city, fx_date) 组合 → 用新值覆盖旧值(ON DUPLICATE KEY UPDATE)
  228. #
  229. # 示例:
  230. # 首次拉取 ("北京", 2026-04-28) → INSERT 新行
  231. # 再次拉取 ("北京", 2026-04-28) → UPDATE 该行所有天气字段,同时更新 update_time
  232. #
  233. # VALUES(col) 表示 "要插入的那份新值",而非表中原有的旧值
  234. insert_query = """
  235. INSERT INTO weather_data (city, fx_date, sunrise, sunset, moonrise, moonset, moon_phase,
  236. moon_phase_icon,
  237. temp_max, temp_min, icon_day, text_day, icon_night, text_night,
  238. wind360_day, wind_dir_day, wind_scale_day, wind_speed_day,
  239. wind360_night, wind_dir_night, wind_scale_night, wind_speed_night,
  240. precip, uv_index, humidity, pressure, vis, cloud, update_time)
  241. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  242. %s, %s, %s, %s, %s, %s, %s)
  243. ON DUPLICATE KEY UPDATE sunrise = VALUES(sunrise),
  244. sunset = VALUES(sunset),
  245. moonrise = VALUES(moonrise),
  246. moonset = VALUES(moonset),
  247. moon_phase = VALUES(moon_phase),
  248. moon_phase_icon = VALUES(moon_phase_icon),
  249. temp_max = VALUES(temp_max),
  250. temp_min = VALUES(temp_min),
  251. icon_day = VALUES(icon_day),
  252. text_day = VALUES(text_day),
  253. icon_night = VALUES(icon_night),
  254. text_night = VALUES(text_night),
  255. wind360_day = VALUES(wind360_day),
  256. wind_dir_day = VALUES(wind_dir_day),
  257. wind_scale_day = VALUES(wind_scale_day),
  258. wind_speed_day = VALUES(wind_speed_day),
  259. wind360_night = VALUES(wind360_night),
  260. wind_dir_night = VALUES(wind_dir_night),
  261. wind_scale_night = VALUES(wind_scale_night),
  262. wind_speed_night = VALUES(wind_speed_night),
  263. precip = VALUES(precip),
  264. uv_index = VALUES(uv_index),
  265. humidity = VALUES(humidity),
  266. pressure = VALUES(pressure),
  267. vis = VALUES(vis),
  268. cloud = VALUES(cloud),
  269. update_time = VALUES(update_time) \
  270. """
  271. try:
  272. # 【更新内部步骤 5.3】逐天写入并提交;本天失败则回滚本天事务,不撤销此前已提交日期。
  273. cursor.execute(insert_query, values)
  274. print(
  275. f"{city} {fx_date} 数据写入/更新成功: {day.get('textDay')}, 影响行数: {cursor.rowcount}"
  276. )
  277. # 本天 INSERT/UPDATE 成功后提交事务,下一天的写入属于后续操作。
  278. conn.commit()
  279. print(f"{city} 事务提交完成。")
  280. except mysql.connector.Error as e:
  281. print(f"{city} {fx_date} 数据库错误: {e}")
  282. # 本天执行异常时撤销尚未提交的修改;except 结束后外层 for 仍可继续处理下一天。
  283. conn.rollback()
  284. print(f"{city} 事务回滚。")
  285. def update_weather(force_update=False):
  286. """
  287. 遍历配置中的所有城市,判断是否需要拉取最新天气数据并入库。
  288. 流程图:
  289. 开始
  290. │
  291. ├── connect_db() ──► 建立 MySQL 连接
  292. │
  293. ├── 遍历 cfg.weather_city_codes 中的每个 (city, location)
  294. │ │
  295. │ ├── get_latest_update_time(cursor, city)
  296. │ │ └── SELECT MAX(update_time) FROM weather_data
  297. │ │ └──► latest_time = datetime / None
  298. │ │
  299. │ ├── should_update_data(latest_time, force_update) 判断:
  300. │ │ ├── force_update == True ──► True(强制更新)
  301. │ │ ├── latest_time == None ──► True(首次拉取)
  302. │ │ └── 距今 > 24 小时 ──► True(数据过期)
  303. │ │ └── 其他情况 ──► False(跳过)
  304. │ │
  305. │ ├── True 分支(需要更新):
  306. │ │ ├── fetch_weather_data(city, location)
  307. │ │ │ └──► HTTP GET → 和风天气 API → gzip 解压 → 返回 JSON dict
  308. │ │ │
  309. │ │ ├── data 有效?
  310. │ │ │ ├── 是 ──► store_weather_data(conn, cursor, city, data)
  311. │ │ │ │ └──► 解析 daily 数组 → 逐条 UPSERT → commit
  312. │ │ │ └── 否 ──► 跳过该城市(打印警告)
  313. │ │
  314. │ └── False 分支(数据已最新):
  315. │ └──► 打印 "数据已为最新,无需更新"
  316. │
  317. └── cursor.close() / conn.close() ──► 结束
  318. """
  319. # 【数据准备 C3,内部步骤 1】建立连接并遍历配置城市;每个城市分别判断更新条件。
  320. # 由主入口先调用一次,之后可能由 schedule 在到期时调用。force_update 默认 False。
  321. # 城市之间串行处理:获取更新时间 → 决定是否拉取 → 检查返回 → 存储,再处理下一城市。
  322. conn = connect_db()
  323. cursor = conn.cursor()
  324. for city, location in cfg.weather_city_codes.items():
  325. # 拿取 这个 城市city 在数据表weather_data中最新更新时间。
  326. latest_time = get_latest_update_time(cursor, city)
  327. # 如果没有更新时间 或者 更新时间大于24小时 或者 force_update=True,就返回True,就去更新数据库中的这个城市的天气数据。
  328. # 其他情况返回False,表示不更新数据库中的数据。
  329. # True 才进入 HTTP 拉取分支;False 只打印“已最新”,然后循环自然进入下一个城市。
  330. if should_update_data(latest_time, force_update):
  331. print(f"开始更新 {city} 天气数据...")
  332. # 从第三方API拿取天气数据
  333. data = fetch_weather_data(city, location)
  334. # 先检查是否取得非空返回值;真正的天气业务 code 仍由 store_weather_data 继续验证。
  335. if data:
  336. # 把从第三方拿到的天气数据(json)解析出来,组装成可以插入到mysql天气表中的insert语句。然后执行插入动作。
  337. store_weather_data(conn, cursor, city, data)
  338. else:
  339. print(f"{city} 数据已为最新,无需更新。最新更新时间: {latest_time}")
  340. # 【更新内部步骤 6】正常完成循环后释放游标和连接。
  341. cursor.close()
  342. conn.close()
  343. def setup_scheduler():
  344. # 仅当 weather_source 等于 "api" 时跳过定时调度;这不会取消此前已执行的更新。
  345. # 【数据准备 C4.1】api 模式仅跳过这里的定时循环;__main__ 前面的建表和首次更新仍会执行。
  346. # 由主入口在首次更新以后调用。api 模式立即 return,其他模式注册每日任务并进入常驻循环。
  347. # 函数只有一直运行,schedule.run_pending 才有机会检查到期任务;注册动作不会创建系统定时任务。
  348. if cfg.weather_source == "api":
  349. return
  350. # 【数据准备 C4.2】注册每天的运行时间;当前 .at 未显式传入时区,按运行进程的本地时间调度。
  351. # do 接收的是函数对象 update_weather,没有括号;只有任务到期时才会调用它。
  352. # 如果写成 do(update_weather()) 就会先立即执行函数,再把返回值交给调度器,这与当前逻辑不同。
  353. schedule.every().day.at(cfg.weather_schedule_time).do(update_weather)
  354. # 【数据准备 C4.3】每 60 秒检查到期任务,不是每 60 秒都请求一次天气。进程退出后调度停止。
  355. while True:
  356. schedule.run_pending()
  357. # 暂停当前线程 60 秒,降低检查频率;这会阻塞此采集脚本线程,不是 async 的协程暂停。
  358. time.sleep(60)
  359. if __name__ == "__main__":
  360. # 初始检查和更新
  361. # 【数据准备 C2】直接运行时先确保 weather_data 表存在,再调用首次更新和调度器。
  362. with mysql.connector.connect(**get_db_config()) as conn:
  363. cursor = conn.cursor()
  364. cursor.execute("""
  365. CREATE TABLE IF NOT EXISTS weather_data
  366. (
  367. id INT AUTO_INCREMENT PRIMARY KEY,
  368. city VARCHAR(50) NOT NULL COMMENT '城市名称',
  369. fx_date DATE NOT NULL COMMENT '预报日期',
  370. sunrise TIME COMMENT '日出时间',
  371. sunset TIME COMMENT '日落时间',
  372. moonrise TIME COMMENT '月升时间',
  373. moonset TIME COMMENT '月落时间',
  374. moon_phase VARCHAR(20) COMMENT '月相名称',
  375. moon_phase_icon VARCHAR(10) COMMENT '月相图标代码',
  376. temp_max INT COMMENT '最高温度',
  377. temp_min INT COMMENT '最低温度',
  378. icon_day VARCHAR(10) COMMENT '白天天气图标代码',
  379. text_day VARCHAR(20) COMMENT '白天天气描述',
  380. icon_night VARCHAR(10) COMMENT '夜间天气图标代码',
  381. text_night VARCHAR(20) COMMENT '夜间天气描述',
  382. wind360_day INT COMMENT '白天风向360角度',
  383. wind_dir_day VARCHAR(20) COMMENT '白天风向',
  384. wind_scale_day VARCHAR(10) COMMENT '白天风力等级',
  385. wind_speed_day INT COMMENT '白天风速 (km/h)',
  386. wind360_night INT COMMENT '夜间风向360角度',
  387. wind_dir_night VARCHAR(20) COMMENT '夜间风向',
  388. wind_scale_night VARCHAR(10) COMMENT '夜间风力等级',
  389. wind_speed_night INT COMMENT '夜间风速 (km/h)',
  390. precip DECIMAL(5, 1) COMMENT '降水量 (mm)',
  391. uv_index INT COMMENT '紫外线指数',
  392. humidity INT COMMENT '相对湿度 (%)',
  393. pressure INT COMMENT '大气压强 (hPa)',
  394. vis INT COMMENT '能见度 (km)',
  395. cloud INT COMMENT '云量 (%)',
  396. update_time DATETIME COMMENT '数据更新时间',
  397. UNIQUE KEY unique_city_date (city, fx_date)
  398. ) ENGINE = InnoDB
  399. DEFAULT CHARSET = utf8mb4
  400. COLLATE = utf8mb4_unicode_ci COMMENT ='天气数据表'
  401. """)
  402. conn.commit()
  403. # 立即执行一次更新
  404. update_weather()
  405. # 我们全国有2000个城市
  406. # 启动定时任务
  407. setup_scheduler()
  408. # 以下是另一种按查询触发的缓存设计思路,当前脚本未实现:
  409. # 1 用户来查询某个城市 比如 北京的天气数据。
  410. # 2 去数据库中查询北京天气数据,select weather_content,update_time from weather_data where city = "北京" and date = "2026-03-04"
  411. # 3 检验这条数据的update_time,如果是1小时之前的,就从第三方api拉取最新数据,返回给用户。同时更新mysql数据。
  412. # 4 如果这条数据是1小时之内更新的,就直接返回给用户。
  413. # 5 做一个缓存,缓存的key是 city+date,设置过期时间ttl,当前时间-更新时间(1小时之内)
  414. # 以上是第一种方式
  415. # 其他方式:
  416. # 从redis中查,有就用,没有就查第三方api,缓存redis,设置ttl=1小时。
  417. # {"code":"200",
  418. # "updateTime":"2026-02-04T10:42+08:00",
  419. # "fxLink":"https://www.qweather.com/weather/beijing-101010100.html",
  420. # "daily":[
  421. # {"fxDate":"2026-02-04","sunrise":"07:21","sunset":"17:37","moonrise":"20:21",
  422. # "moonset":"08:41","moonPhase":"亏凸月","moonPhaseIcon":"805","tempMax":"12",
  423. # "tempMin":"-2","iconDay":"100","textDay":"晴","iconNight":"150","textNight":"晴",
  424. # "wind360Day":"225","windDirDay":"西南风","windScaleDay":"1-3","windSpeedDay":"3",
  425. # "wind360Night":"0","windDirNight":"北风","windScaleNight":"1-3","windSpeedNight":"16",
  426. # "humidity":"29","precip":"0.0","pressure":"1016","vis":"25","cloud":"0","uvIndex":"3"}
  427. # 。。。。
  428. # "refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}