☰
微信公众号天气通知全链路:模板消息、access_token缓存与定时调度
2026/11/6 6:58:08 网站建设 项目流程

简介:面向零基础或仅有少量编程经验的读者,这份教程型资源围绕微信公众号每日自动推送天气给伴侣展开,核心是借助 GitHub Actions 定时运行脚本,将天气与纪念日信息通过公众号模板消息送达,解决手动查询、容易遗忘的问题。压缩包共 5 个文件、约 5KB,包含 py 主程序、yml 工作流配置、txt 配置说明以及 md 文档,分别承担推送逻辑、定时任务、参数填写和使用指引;结构精简,便于按文件逐步理解与改造。资源整理成品、源码与消息模板,并区分完整版、简化版、星座版及 PHP 版等形态,方便读者依据 API 申请情况和技术栈选用;同时保留可替换的配置项与模板结构,为二次修改留出空间。已有 7701 人学习,适合希望以较低成本完成日常关怀自动化、并练习脚本与工作流配置的入门者参考。

1. 每天早上七点,手机震一下:一条公众号天气通知的完整链路

做「微信公众号天气通知」这件事,真正难的从来不是调天气接口,而是把四个环节串成一条能连续跑几个月不出错的链路:拿到公众号的三把钥匙(appid、secret、openid)、按模板结构组装消息、拿到有效期两小时的 access_token、再让一台没人管的机器每天准时触发。任何一环断掉,收到的就是一条沉默——不是报错,是干脆什么都没有。

这套东西适合两类人:想给固定一两个人做每日提醒的,以及想拿它练手「定时任务 + 第三方 API + 消息推送」这条标准链路的。下面按我实际搭的顺序拆,从申请测试号讲到调度和排错,代码可以直接抄。

2. 三条凭证先拿到手:appid、secret 与 openid 的获取路径

2.1 测试号与正式服务号:先用哪个跑通

公众号体系里,推送能力跟账号类型强绑定。订阅号基本没有模板消息能力,服务号要认证之后才稳,个人主体能申请到什么、能不能认证,规则隔一段时间就变,申请之前先去后台确认一遍,别照着两年前的帖子操作。真正适合练手的是「公众平台测试号」——它是一个沙盒账号,扫码就能进,appid 和 appsecret 直接给,模板可以自己随便加字段,接收人扫码关注后立刻能拿到 openid。整条链路能在半小时内跑通,再决定要不要迁到正式号。

测试号的入口在公众平台后台的开发者工具里,进去之后页面会直接列出 appid 和一串 appsecret。这里有个细节:appsecret 只在页面上展示,不提供重置按钮,一旦刷新页面丢了就得重新申请测试号。我一般第一时间把这两个值写进本地.env文件,同时确认.gitignore里已经把它排掉,血泪经验是很多人第一次把 secret 推到公开仓库,第二天就发现账号被顶掉。

拿到 appid 和 secret 之后还需要接收人的 openid。测试号页面上有个「用户列表」,让接收人扫页面上的二维码关注,刷新列表就能看到一串o开头的字符串,那就是 openid。如果接收人换人、或者中途取关重关,openid 会变,这一点在第 5 章会展开。

2.2 模板消息的字段结构:为什么你的 data 会报 40037

模板消息的报文长这样:touser(openid)、template_id(模板编号)、url(点击跳转,可空)、data(字段字典)。data里每个 key 必须跟模板正文里{{xxx.DATA}}的xxx严格对应,多一个少一个都会失败。

在测试号里加模板时,正文自己写,比如:

{{date.DATA}} {{city.DATA}} 今天 {{low.DATA}}~{{high.DATA}}℃,{{weather.DATA}} {{tip.DATA}}

对应的data就必须是date、city、low、high、weather、tip六个 key,每个值是一个{"value": "...", "color": "#173177"}形式的对象。color可以省略,但value必须是字符串——把温度写成数字26而不是"26",接口会返回 40037(invalid template_id)或直接报参数错误,排查时会绕很大一圈。这是新手最常见的一个翻车点,报错信息指向 template_id,实际错在 data 的类型上。

字段是否必填说明常见错误
touser是接收人 openid用错人的 openid,报 40003
template_id是模板编号与账号不匹配,报 40037
url否点击跳转链接,空串也可以带未备案域名会被拦
data是字段字典,key 必须与模板一致key 拼错、value 非字符串
miniprogram否跳小程序用一般不用

模板正文里的换行也要注意。测试号编辑框里敲的回车,在手机端可能被吞掉,稳妥的做法是在value里用真正的换行符,并且在手机上实际看一遍效果再定稿。我习惯在本地先发一条固定假数据,确认排版没问题,再接入天气接口——先验证消息通道,再验证数据源,出问题时才知道该查哪一头。

2.3 天气数据源怎么选:免费额度、字段和刷新时间

天气接口分两类:一类按天返回预报数组,一类实时天气。做每日早报只需要前者。国内可选的免费或低价服务商不少,字段大同小异,选的时候盯三件事:免费额度够不够每天一次调用、返回里有没有最高最低温加天气现象文本、以及有没有按城市名查询的接口。

我一般选同时满足这三条的:请求形如GET https://api.example-weather.com/v7/weather/3d?location=101010100&key=xxx,返回一个daily数组,daily[0]是今天,daily[1]是明天。关键字段包括tempMax、tempMin、textDay(白天天气现象,如「小雨」)、precip(降水量)、humidity、windDir、windScale。城市定位建议用城市 ID 而不是中文名,中文名在部分接口上会因为编码问题匹配失败,返回「未知城市」。

免费额度普遍是每天几百到上千次调用,每天一次完全够用,但要留意它按「请求次数」而不是「用户数」计费。如果你想给多个人发同一座城市的天气,正确做法是调一次接口、拼多条消息,而不是循环里每人调一次——这个选择在人数上来之后差别很大。

API key 和公众号的 secret 一样,属于不该进代码仓库的东西,统一走环境变量。下面整章的脚本都按这个约定来。

3. 一份 120 行的 Python 脚本:把天气 JSON 拼成模板消息

3.1 access_token 的获取与本地缓存

access_token 是这个项目里最容易出事的一环。它是调用模板消息接口的通行证,有效期 7200 秒,而且同一个 appid 重复获取会让旧 token 在短时间内失效。如果你每次发送都重新获取,单人场景看不出问题,一旦你有两个定时任务或者手动测试和定时任务撞上,就会出现「刚拿到就失效」的玄学现象。

常见做法是缓存到本地文件,带过期时间,用的时候读文件、判断剩余有效期是否超过一个安全余量(我一般留 300 秒),不够才重新请求。

import json import os import time import requests APPID = os.environ["WX_APPID"] SECRET = os.environ["WX_SECRET"] TOKEN_CACHE = os.path.expanduser("~/.wx_weather_token.json") def get_access_token(force: bool = False) -> str: """从缓存读 token,剩余有效期不足 300 秒或强制刷新时才请求接口。""" if not force and os.path.exists(TOKEN_CACHE): with open(TOKEN_CACHE, "r", encoding="utf-8") as f: cache = json.load(f) # expire_at 是绝对时间戳,避免跨机器时区问题 if cache.get("expire_at", 0) - time.time() > 300: return cache["access_token"] resp = requests.get( "https://api.weixin.qq.com/cgi-bin/token", params={ "grant_type": "client_credential", "appid": APPID, "secret": SECRET, }, timeout=10, ) data = resp.json() if "access_token" not in data: # 常见:40125 secret 错误、45009 取 token 次数超限 raise RuntimeError(f"get token failed: {data}") cache = { "access_token": data["access_token"], "expire_at": time.time() + int(data.get("expires_in", 7200)), } with open(TOKEN_CACHE, "w", encoding="utf-8") as f: json.dump(cache, f) return cache["access_token"]

逻辑很直白:先看缓存,不够期才请求;请求成功后把绝对过期时间写回文件。expire_at存绝对时间戳而不是「还剩多少秒」,是因为跨机器、跨时区、跨进程读同一个文件时,相对时间会算错。timeout=10必须有,微信接口偶发慢响应,没有超时的话定时任务会挂在那里直到平台整体超时。

有个坑要提前说:这个文件在多实例部署时不是共享的。如果你在云函数上跑,每次冷启动都是一个新环境,文件缓存等于没有,这时候应该把 token 存到外部存储(对象存储、数据库、Redis 都行)。单人自用跑在一台常开的机器上,文件方案最省事。

3.2 天气接口的请求与关键字段提取

天气接口的返回结构各家不同,但套路一致:一个daily或forecast数组,按天排列。写解析时不要直接下标硬取,先判断状态码和数组长度,否则接口偶发返回空数组时会抛IndexError,日志里只剩一行 traceback,看不出是接口的问题。

WEATHER_KEY = os.environ["WEATHER_KEY"] CITY_ID = os.environ.get("CITY_ID", "101010100") # 默认示例城市,换成你的 def get_weather() -> dict: """取今明两天预报,返回结构化字典。""" resp = requests.get( "https://api.example-weather.com/v7/weather/3d", params={"location": CITY_ID, "key": WEATHER_KEY}, timeout=10, ) payload = resp.json() # 各家的成功码不一样,通常是 "200" 或 "ok",先用真实返回验证一次 if str(payload.get("code")) != "200": raise RuntimeError(f"weather api error: {payload}") daily = payload.get("daily") or [] if len(daily) < 2: raise RuntimeError("weather daily array too short") today, tomorrow = daily[0], daily[1] return { "date": today.get("fxDate", ""), "text_today": today.get("textDay", "未知"), "temp_min": today.get("tempMin", "?"), "temp_max": today.get("tempMax", "?"), "precip": today.get("precip", "0"), "humidity": today.get("humidity", "?"), "wind": f"{today.get('windDir', '')}{today.get('windScale', '')}级", "text_tomorrow": tomorrow.get("textDay", "未知"), "temp_min_tomorrow": tomorrow.get("tempMin", "?"), "temp_max_tomorrow": tomorrow.get("tempMax", "?"), }

参数方面,location用城市 ID 最稳,key走环境变量,timeout同样别省。返回里我特意多取了明天的字段,因为一条只有今天的天气通知读起来很像机器,加上一句「明天转小雨,记得带伞」才有存在感,这段在第 6 章会用到。

fxDate是预报对应的日期,格式一般是2025-01-01。建议在发送前把它和本地日期比对一次:如果接口返回的还是昨天的数据(缓存或延迟),你发出去的日期就是错的,这在跨零点调用时真实发生过。

3.3 模板消息报文组装与发送

组装阶段的核心是「key 对齐」和「值全为字符串」。我习惯写一个build_data函数统一做str()转换,避免某天从接口拿到数字类型直接把报文搞坏。

TEMPLATE_ID = os.environ["WX_TEMPLATE_ID"] TOUSER = os.environ["WX_TOUSER"] def build_data(w: dict) -> dict: """把天气字典映射成模板变量,所有 value 强制转字符串。""" tip = f"明天{w['text_tomorrow']},{w['temp_min_tomorrow']}~{w['temp_max_tomorrow']}℃" if float(w["precip"] or 0) > 0: tip += ",可能有降水,出门带伞" raw = { "date": w["date"], "city": os.environ.get("CITY_NAME", "本地"), "low": w["temp_min"], "high": w["temp_max"], "weather": w["text_today"], "tip": tip, } return {k: {"value": str(v)} for k, v in raw.items()} def send_template(token: str, data: dict) -> dict: """调用模板消息接口,返回微信原始响应。""" body = { "touser": TOUSER, "template_id": TEMPLATE_ID, "url": os.environ.get("JUMP_URL", ""), "data": data, } resp = requests.post( "https://api.weixin.qq.com/cgi-bin/message/template/send", params={"access_token": token}, json=body, timeout=10, ) return resp.json() if __name__ == "__main__": token = get_access_token() weather = get_weather() result = send_template(token, build_data(weather)) print(json.dumps(result, ensure_ascii=False)) # errcode 为 0 才算成功 if result.get("errcode") != 0: raise SystemExit(1)

几点说明。token放在 query 参数里,是接口规定的形式,不是随手写的。json=body让 requests 自动带Content-Type: application/json,别手动拼字符串。最后用errcode != 0抛退出码,是为了让定时任务的日志和告警能直接看出成败——很多人只看print输出,任务明明失败了,日志里躺着一行{"errcode":40001}却没人发现。

build_data里之所以把precip做float()转换,是因为部分接口把它返回成字符串"0.0",直接比> 0在 Python 3 里会抛类型错误。这种「字段类型不统一」的问题在免费接口上很常见,防御性地转一次比事后排查便宜得多。

3.4 本地跑通后的四项自检

脚本第一次在本地跑通不代表就稳了。我固定做四件事:一是把get_access_token(force=True)手动调一次,确认新 token 能覆盖旧缓存;二是把TOUSER改成一个错的 openid,看错误码是不是 40003,确认异常路径能走通;三是把天气接口的location改成一个不存在的 ID,确认脚本会失败退出而不是发一条满是问号的消息;四是把模板里的某个 key 改错一个字,确认能复现 40037。

这四步做完,你对每个环节的失败形态就有了印象,后面在服务器上出事时不用从零猜。这比看十篇教程管用。

4. 让它每天自己醒来:三种定时调度方案的取舍与配置

4.1 crontab 方案:最小可用配置与时区坑

有一台常开的机器(家里的旧笔记本、云主机都行)就用 crontab,最简单也最好查。

# 每天早上 7:00 执行,输出追加到日志 0 7 * * * cd /opt/wx_weather && /usr/bin/python3 main.py >> /var/log/wx_weather.log 2>&1 # 查看当前用户的定时任务 crontab -l # 确认系统时区(cron 跟随系统时区) timedatectl | grep "Time zone"

第一行的五个字段依次是分、时、日、月、周。0 7 * * *就是每天 7 点整。cd进项目目录是因为脚本用相对路径读.env,不cd会读不到配置。2>&1把 stderr 也重定向到同一份日志,报错才不会丢。

时区是这个方案最大的坑。云主机默认常常是 UTC,0 7 * * *在当地时间就是下午三点。要么改系统时区,要么在 crontab 顶部加一行CRON_TZ=Asia/Shanghai(部分 cron 实现支持,Debian 系的 cron 就支持,busybox 的不支持)。我一般两者都做:改系统时区,同时用date命令确认一遍。

还有一点,cron 的环境变量跟你登录 shell 的环境变量不是一套,.env如果靠source加载,cron 里不会执行。稳妥做法是在脚本里用python-dotenv主动读文件,或者直接在 crontab 里写BASH_ENV。这个坑我第一次踩的时候,本地好好的,服务器上一直报KeyError: 'WX_APPID'。

4.2 云函数定时触发:适合没有常开服务器的场景

没有常开机器,就用云函数的定时触发器。各家控制台的入口不同,但流程一样:新建函数、上传代码或填在线编辑器、配依赖、设环境变量(把 appid、secret、模板 ID、天气 key 都填进去)、最后加一条定时触发规则,cron 表达式写成每天 7 点。

云函数场景下有两个和本地不一样的约束。第一是执行时长,一般限制在几十秒到几分钟,正常一次请求加发送不到两秒,够用,但要把timeout设短,别用默认的三十秒去等一个挂住的接口。第二是无状态,前面说的文件缓存 token 在这里等于没有,每次冷启动都是新环境。解法是把 token 存到云厂商的对象存储或数据库里,读写一次的成本远低于每半小时取一次 token 的额度消耗。

如果不想用云函数,托管平台的 Actions 定时任务也行,写一个 yaml 就够了:

name: daily-weather-push on: schedule: - cron: "0 23 * * *" # UTC 23 点 = 北京时间次日 7 点 workflow_dispatch: # 支持手动触发,方便调试 jobs: push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - run: pip install requests python-dotenv - run: python main.py env: WX_APPID: ${{ secrets.WX_APPID }} WX_SECRET: ${{ secrets.WX_SECRET }} WX_TEMPLATE_ID: ${{ secrets.WX_TEMPLATE_ID }} WX_TOUSER: ${{ secrets.WX_TOUSER }} WEATHER_KEY: ${{ secrets.WEATHER_KEY }}

cron字段用的是 UTC,写0 7 * * *会变成北京时间下午三点,这个换算错误我见过不止一次。workflow_dispatch让你可以在网页上手动点一次,调试阶段非常有用。定时任务本身不保证准点,延迟几分钟是常态,对天气通知来说完全无所谓。

代价是 token 缓存同样没地方放,而且仓库的 secrets 无法跨仓库复用。真要用这条路,把 token 存到外部 KV 或者干脆接受每次取一次 token,但要注意每天取 token 的次数上限。

4.3 失败可见:日志、重试与「发没发出去」的判断

比「怎么定时」更重要的是「失败了你怎么知道」。我的做法分三层。

第一层是退出码。脚本里任何一步失败都raise,让进程以非零码退出,cron 和 Actions 都能识别。第二层是失败重试,只对网络类错误重试一次,间隔几秒,因为微信接口和天气接口的偶发超时占了失败的绝大多数。写个简单的装饰器就够,别引入重试框架。

import time def retry_once(func): """只对异常重试一次,避免把频率类错误放大成限流。""" def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as first_err: time.sleep(3) try: return func(*args, **kwargs) except Exception: raise first_err return wrapper

重试的边界要清楚:如果第一次失败的原因是 45009(调用超限)或者 40003(openid 无效),重试只会白费一次调用,还可能把限流坐实。所以更好的写法是在发送函数里判断 errcode,只有网络异常和 5xx 才重试,业务错误直接抛。

第三层是「今天到底发没发出去」。最土也最有效的办法是在脚本成功发送后往本地日志写一行带日期的记录,第二天启动时先读这行,如果昨天的日期在、今天的不在,说明昨天漏发了,可以在这次消息里加一句提示,或者另外发一条。这套逻辑写起来二十行,但能让你在连续跑三个月之后依然信任它。

5. 避坑与排查:40001、43004、45009 背后的真实原因

5.1 现象:手动测试正常,第二天定时任务报 40001

现象:本地跑得好好的,服务器上偶尔报{"errcode":40001,"errmsg":"invalid credential"},隔一阵又自己好了。

原因:40001 是 token 无效。最常见的是缓存文件被两个进程同时读写——你在本地调试取了新 token,服务器上的定时任务还在用旧缓存,而微信侧同一个 appid 新 token 生效后旧 token 会短暂失效。另一种是缓存文件权限不对,读到了空内容。

解决:token 的读写加文件锁,或者干脆让所有调用方共用一个服务来取 token。单人场景最简单的做法是:只在定时任务里取 token,本地调试时不要和服务器的缓存文件共用路径,通过环境变量把缓存路径区分开。另外每次报 40001 后不要立刻重试,先强制刷新一次 token 再发。

5.2 现象:返回 43004,提示 require subscribe

现象:接口返回{"errcode":43004,"errmsg":"require subscribe"}。

原因:这个 openid 对应的用户没有关注该公众号,或者中途取关了。测试号上的用户列表里,取关的人有时候不会立刻从列表消失,你看着还在,实际已经发不出去。另一种情况是 openid 复制时多了空格或换了全角字符。

解决:先确认接收人当前是否关注,重新扫一次码看 openid 有没有变化。代码里对 openid 做一次strip(),并把 43004 单独识别出来,日志里明确写「接收人未关注」,而不是抛一个看不懂的错误码。如果是给多人发,把失败的 openid 记下来跳过,不要让一个取关的人拖垮整批任务。

5.3 现象:跑了一段时间后开始 45009,或者根本发不出去

现象:连续正常几天后,接口返回{"errcode":45009,"errmsg":"reach max api daily quota limit"}。

原因:模板消息有每日调用上限,取 access_token 也有独立的次数限制。免费接口的天气调用同样有额度。最常见的原因是调试阶段在循环里反复跑,一天就把额度耗光,或者把「重试」写成了「循环重试直到成功」。

解决:给发送函数加一道幂等保护——同一天同一个接收人只发一次,用一个日期文件做标记。调试时用--dry-run参数只打印报文不发送。额度恢复通常是次日零点,不用慌张,但也别写死循环去等。

5.4 现象:消息发出去了,但天气是昨天的

现象:早上七点准时收到消息,日期和天气却是前一天的。

原因:天气接口有缓存,尤其按城市 ID 查询时,免费档的刷新间隔可能是十几分钟到一小时。你在七点整请求,拿到的可能是六点半的数据。另一种原因是城市 ID 填错,接口做了模糊匹配返回了邻近城市。

解决:请求里带上日期参数(如果接口支持),或者在发送前比对返回的fxDate和本地日期,不一致就重试一次并稍微等待。城市 ID 用接口提供的城市搜索功能查一次,把返回值固化到配置里,别用中文城市名。

5.5 现象:周末和周一的时间对不上

现象:工作日正常,周一早上没收到,或者收到两条。

原因:cron 表达式写成了0 7 * * 1-5却以为每天都发,或者跨时区换算时把周一算错。还有一种更隐蔽的,服务器时间同步漂移,cron 触发了但脚本判断「今天已经发过」而跳过。

解决:把 cron 表达式和预期行为写成注释贴在配置文件旁边,每季度对一次。脚本里的「今天」统一用datetime.now(ZoneInfo("Asia/Shanghai"))取,不要依赖系统默认时区。发送记录的日期也用它,别用 UTC 的date。

6. 让这条通知像人说的话:文案映射表与上线前的三组验证

模板消息的字段是死的,但值是活的。同样一组数字,直接拼成「26~33℃,多云」和加工成「今天 26 到 33 度,多云,体感偏闷,短袖就行」,收到的人反应完全不同。我一般维护一张小的映射表,按温度和天气现象组合出提示语,逻辑简单但效果好。

条件生成提示说明
tempMax >= 35高温,注意补水和防晒优先级最高,覆盖其他提示
tempMax - tempMin >= 10昼夜温差大,早晚加件外套春秋季最常见
textDay 含「雨」有雨,出门带伞关键词匹配,别写死全部天气文本
precip > 0 且 textDay 不含雨可能有零星降水数据与文本不一致时的兜底
以上都不满足天气平稳,按平时的穿法就行必须有兜底,否则消息会有空字段

实现上就是一串if,别上规则引擎。要注意的是顺序:高温判断放最前面,因为高温天同时温差大时,先说防晒比说加外套合理。关键词匹配用in就够,「小雨」「中雨」「阵雨」「雷阵雨」都能命中「雨」字,比枚举稳。

上线前我固定跑三组数据。第一组是当天的真实数据,确认字段都能取到;第二组手工构造极端值——最高温 38、最低温 5、天气「暴雨」,看提示语会不会同时冒出两条互相矛盾的话;第三组把天气接口的返回值改成空数组,确认脚本是失败退出而不是发一条全是问号的消息。这三组跑完,基本能覆盖上线后一个月内会遇到的情况。

还有一个验证习惯是看消息到达时间。定时任务设七点,实际到达可能在七点零一分到七点零五分之间,这是正常的。但如果你发现连续几天都超过七点二十,那就是调度平台在排队,需要把触发时间提前,或者换一个调度方式。

说个我自己的教训。最早搭这套东西时,我把 token 缓存、天气请求、消息发送全塞在一个函数里,出错时日志只有一行 traceback,根本分不清是哪一步。后来强制自己把每一步拆成独立函数、每个函数失败都带上自己的上下文再抛,排错时间从半小时降到两分钟。从那以后我每次接这类「定时 + 第三方接口」的小项目,都强制先把失败路径写好再写成功路径,先让它在出错时说话,再让它准点报天气。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询