资讯详情 Python公众号每日天气模板消息定时推送实战
📅 2026/10/29 23:46:49
简介一套面向具备少量编程基础的开发者的微信公众号天气通知制作资料围绕每日定时向指定联系人推送天气与纪念日信息这一需求提供可参考的实现思路与配置说明。压缩包共 5 个文件包含 Python 脚本、YAML 工作流配置、TXT 配置/说明以及 Markdown 文档整体约 5KB体量轻便便于快速浏览与按需取用。已有 7698 人学习说明该场景在个人项目与练手实践中具有一定关注度。资料重点呈现 GitHub 每日自动运行版本的脚本组织、参数配置与消息模板思路读者可据此了解定时任务、接口调用和推送内容的组合方式并对照最新版、简化版、星座版等不同实现方向做取舍适合希望用较小成本完成一个可展示、可复用的微信推送小项目或需要参考配置结构与部署逻辑的开发者。1. 早上七点准时响的那条消息到底是怎么发出来的有人问过我一个很具体的问题能不能让公众号每天早上七点自动给他女朋友发一条天气消息带上温度、穿衣建议末尾再挂一句自己写的话。听着像个小玩具拆开看其实是一条完整的消息推送链路第三方天气接口取数、公众号平台换 access_token、模板消息拼装、定时任务拉起脚本。它适合两类人——一类是想给对象做点小惊喜、但只有一点点 Python 基础的开发者另一类是想拿一个最小可跑通的例子搞明白公众号消息推送到底怎么走通的人。代码量不大五个文件、两百行出头但每一步都有权限和格式上的硬约束踩一次坑就记住了。2. 链路与目录一条天气消息要经过几道手先把整条链路摊开看。公众号的每日天气推送本质是一个无状态的一次性脚本拉起 → 取天气 → 换 token → 发一条模板消息 → 退出。它没有数据库、没有常驻进程也不需要用户端做任何交互。这种形态决定了它的部署方式可以极其轻——一台服务器都嫌浪费定时流水线或者云函数就够。2.1 三段式链路取数、拼装、投递整个系统可以切成三段边界很清楚第一段是数据源。调用第三方天气服务的实时天气接口拿到当前温度、天气现象、湿度、风力如果接口支持再带一个生活指数穿衣、紫外线。这一步的产出应该被收敛成一个扁平字典后面的代码只认这个字典不认原始 JSON——换服务商的时候只改这一层。第二段是拼装。把天气字典映射成公众号模板消息要求的结构。模板消息的 data 字段是一个{key: {value, color}}的对象key 的名字必须和你在后台选的模板里的变量名一一对应。这一段最容易翻车因为对不上时报错信息往往只有一句invalid template不会告诉你哪个字段错了。第三段是投递。先拿 access_token再 POST 到模板消息发送接口带上 openid 和 template_id。token 有效期 7200 秒每天跑一次的脚本其实每次都是重新取缓存意义不大但如果你顺手做了个手动触发的调试入口一天点几十次就需要注意别把取 token 的日配额刷爆。提示三段之间用函数隔开、各返回一个纯数据结构是这个项目最值钱的设计。后面换成企业微信、钉钉、飞书机器人只需要重写第三段。选型上有几个现实约束值得先说清楚。个人能注册的订阅号现在没有模板消息权限走的是「一次性订阅通知」用户每授权一次只能推一条无人值守的每日推送做不了。所以绝大多数这类教程走的是公众号平台开发者工具里的测试号——它自带模板消息接口扫码关注后就能拿到 openid不需要认证、不需要备案。代价是测试号的 openid 只对关注了该测试号的人有效正式上线得换成服务号。这个差异在动手前就该知道不然写完发现发不出去白折腾。2.2 目录结构五个文件撑起整个项目项目不需要框架平铺几个文件反而好维护daily-weather-push/ ├── main.py # 入口串起取数 → 拼装 → 投递 ├── weather.py # 天气接口封装对外只暴露 fetch_weather() ├── wechat.py # token 获取 模板消息发送 ├── template.py # 消息体拼装含文案格式化 ├── config.py # 从环境变量读配置集中一处 ├── requirements.txt # requests 一个依赖就够 └── .github/workflows/daily.yml # 定时流水线也可换成云函数触发器config.py单独抽出来是有原因的。这类项目最常见的低级事故就是把 appid、secret 直接写死在main.py里然后推到公开仓库。集中到一个文件、只从环境变量读本地调试时用.env加载线上用流水线的密钥变量注入两套环境一份代码。# config.py import os WX_APPID os.environ[WX_APPID] # 公众号/测试号 appid WX_SECRET os.environ[WX_SECRET] # 开发者密码绝不入库 WX_OPENID os.environ[WX_OPENID] # 接收人的 openid一人一个 WX_TEMPLATE_ID os.environ[WX_TEMPLATE_ID] # 后台模板的 id WEATHER_KEY os.environ[WEATHER_KEY] # 天气服务商的 key CITY_CODE os.environ.get(CITY_CODE, 101010100) # 城市编码按服务商规则 # 自定义文案放在配置里方便随时改不用动业务逻辑 LOVE_NOTE os.environ.get(LOVE_NOTE, 今天也要好好吃饭呀)用os.environ[...]而不是.get()取必填项是故意的缺配置就当场KeyError崩掉比带着空字符串跑一半再报一个莫名其妙的鉴权错误好排查得多。CITY_CODE和LOVE_NOTE给了默认值属于可选项。2.3 凭据管理appid、secret、openid 都放哪四个凭据的敏感级别不一样处理方式也该不一样。WX_SECRET是最高级别泄露等于别人可以拿你的号发消息WX_OPENID和WX_TEMPLATE_ID敏感度低一些但也没必要公开WEATHER_KEY泄露最多是被刷额度。本地开发放一个.env配一个.gitignore第一行就写.env。线上跑流水线的话把这些值填进仓库的 Secrets密钥变量在 workflow 里通过env映射进容器。这样即使仓库是公开的别人也只能看到变量名。# .gitignore —— 这三行先写上再写业务代码 .env __pycache__/ *.log还有一个容易忽略的点天气服务商和公众号平台的请求都有 IP 白名单机制。正式服务号调接口要求把服务器出口 IP 加进白名单流水线的出口 IP 是动态的加不了测试号没有这个限制云函数也可以买固定 IP。这就是为什么「本地跑得好好的上线就 401」——不是代码问题是网络出口换了。3. 核心模块拆解天气抓取、token 缓存与模板消息拼装这一章把三个核心模块的代码逐段过一遍。每段都能直接抄但抄完记得对着注释改参数尤其是字段名和城市编码——这两处是最容易照抄出错的地方。3.1 天气取数把第三方 JSON 收敛成一个扁平字典不同天气服务商的返回结构差别很大有的把实时天气放在data.now有的放在result.realtime字段名从temp到temperature都有。稳妥的做法是在weather.py里做一层防腐对外只吐一个固定格式的字典。# weather.py import requests def fetch_weather(api_key: str, city: str) - dict: 调用实时天气接口返回统一结构的字典。换服务商只改这个函数。 url https://api.example-weather.com/v3/weather/now params { key: api_key, location: city, lang: zh, } # timeout 必须设。定时任务里没有超时的请求会把整条流水线挂死 resp requests.get(url, paramsparams, timeout8) resp.raise_for_status() raw resp.json() if str(raw.get(code)) ! 200: raise RuntimeError(f天气接口异常: {raw}) now raw[now] return { temp: now[temp], # 温度字符串别急着转 int text: now[text], # 天气现象晴 / 多云 / 小雨 humidity: now[humidity], # 相对湿度百分比 wind: f{now[windDir]}{now[windScale]}级, feels: now.get(feelsLike, now[temp]), # 体感温度没有就退回温度 }几个参数值得说明。timeout8是硬性要求我曾经因为没设超时天气服务商那边抖动了一下脚本卡在 socket 上直到流水线超时被杀日志里什么都没有纯黑匣子。raise_for_status()处理 HTTP 层错误code ! 200处理业务层错误——很多天气接口无论成败都返回 200错误信息藏在 body 里只看状态码会以为成功。temp保留字符串是有意为之。很多接口直接给26有的给26.5有的带单位26℃。在这一层不做转换到拼文案的时候统一用str()和格式化处理反而省心。wind字段在取数层就拼好是因为不同服务商的字段拆分方式不同有的windScale是3-4有的是3在这里收敛掉模板层就不用管了。3.2 access_token全局唯一且会过期的门票公众号的所有主动接口都要带 access_token。它的特性是全局唯一新的一次获取会让旧的失效、有效期 7200 秒、每天获取次数有上限。对于每天跑一次的脚本最简单也最正确的做法是——每次跑都重新取一次。# wechat.py import time import requests _token {value: None, expire_at: 0.0} def get_access_token(appid: str, secret: str, force: bool False) - str: 带进程内缓存的 token 获取。单次脚本跑其实用不上缓存 但加上它之后本地反复调试不会把日配额刷爆。 now time.time() if not force and _token[value] and now _token[expire_at]: return _token[value] resp requests.get( https://api.weixin.qq.com/cgi-bin/token, params{ grant_type: client_credential, appid: appid, secret: secret, }, timeout8, ) payload resp.json() if access_token not in payload: # 常见错误码40164 IP 不在白名单、40013 appid 错、40125 secret 错 raise RuntimeError(f获取 token 失败: {payload}) _token[value] payload[access_token] # 提前 5 分钟过期避免边界时刻拿到一个已经失效的 token _token[expire_at] now payload.get(expires_in, 7200) - 300 return _token[value]expire_at减去 300 秒是血泪经验。不提前过期的话恰好卡在 7200 秒边界上调用token 可能刚过期接口返回40001 invalid credential而你手上这个 token 明明「还没到期」。这里要说清楚一个误区进程内缓存对定时脚本的价值有限因为每次流水线都是一个全新的 Python 进程_token字典是空的。真正需要缓存的是常驻服务——那种场景下应该把 token 写进 Redis 或者文件键名带 appid过期时间设为 7000 秒。如果你后面要把它改成常驻的 Flask 服务这一步必须补上否则并发请求会互相把对方的 token 顶掉报40001报到怀疑人生。3.3 模板消息体data 里的每个 key 都要对得上模板模板消息的结构是固定的touser指定收件人 openidtemplate_id指定用哪个模板url是点击消息后跳转的页面可以留空data是变量填充。麻烦的地方在于data的 key 必须和后台模板里的变量名严格一致。# wechat.py 续 def send_template(access_token: str, openid: str, template_id: str, data: dict, url: str ) - str: 发送模板消息返回 msgid。失败抛异常不做静默吞错。 body { touser: openid, template_id: template_id, url: url, data: data, } resp requests.post( https://api.weixin.qq.com/cgi-bin/message/template/send, params{access_token: access_token}, jsonbody, timeout8, ) result resp.json() if result.get(errcode) ! 0: # 43004 用户未关注40003 openid 不合法45002 内容超长 raise RuntimeError(f发送失败: {result}) return result[msgid]data的构造放在template.py里因为它涉及文案格式化和长度控制属于业务逻辑而不是通信逻辑# template.py from datetime import datetime WEEKDAYS [星期一, 星期二, 星期三, 星期四, 星期五, 星期六, 星期日] def build_data(city_name: str, weather: dict, note: str) - dict: now datetime.now() # 模板消息单个 value 建议控制在 20 字以内超长会被截断或直接报 45002 date_str f{now.month}月{now.day}日 {WEEKDAYS[now.weekday()]} return { date: {value: date_str, color: #173177}, city: {value: city_name, color: #173177}, temp: {value: f{weather[temp]}℃, color: #FF6B35}, text: {value: weather[text], color: #173177}, wind: {value: weather[wind], color: #173177}, tip: {value: build_tip(weather), color: #888888}, note: {value: note[:20], color: #FF6B35}, } def build_tip(weather: dict) - str: 根据天气生成一句穿衣/带伞建议规则简单但够用。 text weather[text] temp float(str(weather[temp]).rstrip(℃)) if 雨 in text or 雪 in text: return 有雨雪记得带伞路上慢一点 if temp 5: return 挺冷的羽绒服安排上 if temp 15: return 有点凉外套别忘 if temp 30: return 热多喝水别在外面晒 return 温度刚好穿得舒服点build_tip里的rstrip(℃)是防御性的有些接口返回带单位的值有些返回纯数字统一切一遍再转 float省得某天接口改格式就崩。note[:20]的截断看着粗暴但模板消息对单个 value 有长度限制自定义文案是最不可控的字段截断比线上报错强。注意color字段只有部分模板类型支持不支持的类型传了会被忽略不会报错。所以改配色的时候别指望它一定生效先手动发一条看看效果。3.4 组装 main一次跑通的完整流程入口文件只做编排不写业务逻辑# main.py import sys import config import weather import wechat import template def run() - None: # 1. 取天气 w weather.fetch_weather(config.WEATHER_KEY, config.CITY_CODE) print(f[1/3] 天气取到: {w[text]} {w[temp]}℃) # 2. 换 token token wechat.get_access_token(config.WX_APPID, config.WX_SECRET) print([2/3] token 获取成功) # 3. 拼装并发送 data template.build_data(北京, w, config.LOVE_NOTE) msgid wechat.send_template( token, config.WX_OPENID, config.WX_TEMPLATE_ID, data ) print(f[3/3] 发送成功, msgid{msgid}) if __name__ __main__: try: run() except Exception as e: print(f推送失败: {e}, filesys.stderr) sys.exit(1) # 非零退出码流水线才会标记为失败最后那个sys.exit(1)很多人会漏。异常被 catch 住打印出来进程正常退出流水线显示绿色成功实际上消息根本没发出去——这种「假成功」比直接报错更坑因为你不会去看日志。退出码非零流水线会标红才会有人注意到。4. 定时与部署让脚本每天七点自己醒来代码能在本地跑通只是第一步真正的交付是「明天早上七点它自己会跑」。这里有三种常见落地方式按维护成本从低到高排列。4.1 流水线定时cron 的时区陷阱代码托管平台的定时流水线是最省事的方式不花钱、不用管服务器、日志自动留存。配置通常是仓库里的一个 YAML 文件。# .github/workflows/daily.yml name: daily-weather-push on: schedule: # 注意这里的 cron 走 UTC。北京时间 07:00 UTC 前一天 23:00 - cron: 0 23 * * * workflow_dispatch: # 手动触发按钮调试必备 jobs: push: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.11 cache: pip - name: Install deps run: pip install -r requirements.txt - name: Push weather env: TZ: Asia/Shanghai # 让 datetime.now() 拿到北京时间 WX_APPID: ${{ secrets.WX_APPID }} WX_SECRET: ${{ secrets.WX_SECRET }} WX_OPENID: ${{ secrets.WX_OPENID }} WX_TEMPLATE_ID: ${{ secrets.WX_TEMPLATE_ID }} WEATHER_KEY: ${{ secrets.WEATHER_KEY }} run: python main.py两个参数是必须理解的。cron: 0 23 * * *里的 23 是 UTC 小时对应北京时间次日早上 7 点如果你直接写0 7 * * *实际执行时间是北京时间下午 3 点收件人会在下午收到一句「早安」。TZ: Asia/Shanghai影响的是容器里的本地时间也就是datetime.now()的返回值——不设的话日期和星期几全是错位的日期显示成前一天星期几对不上。这两处一个管事触发时刻一个管文案里的时间缺一不可。流水线方案有个已知限制定时任务不保证准点高峰期可能延迟几分钟到几十分钟仓库长期无提交活动时定时任务会被自动停用。如果对「七点整」有执念得换方案。想要准点云函数定时触发器或者一台常驻小机器更合适。4.2 云函数定时触发器另一种更稳的落地方式把同样五个文件打成 zip 传到云函数平台配置一个每天 07:00 的 cron 触发器运行时选 Python 3.11。代码几乎不用改只需要两处适配。第一处是依赖。云函数环境不自带requests要么把依赖打包进 zip本地pip install -r requirements.txt -t ./后一起压缩要么在控制台配置在线依赖。前者更稳后者偶尔会因为镜像源问题装不上。第二处是时区。多数云函数默认东八区但也有默认 UTC 的务必看文档。判断方法很简单在函数里print(datetime.now())打一行手动触发一次看日志里的时间对不对。# 云函数入口适配把 main.run() 包一层 import main def handler(event, context): try: main.run() return {ok: True} except Exception as e: print(f推送失败: {e}) raise # 抛出去平台的失败告警才能触发云函数的优势是准点触发器精度通常在秒级、有失败重试、能在控制台直观看到每次调用的耗时和错误。劣势是要花钱虽然有免费额度以及固定出口 IP 需要额外配置。提示如果只给自己和一个人用一天一次的调用量连免费额度都跑不满成本可以忽略。真正要衡量的是维护成本——云函数改代码要重新上传流水线改代码只需要 git push。4.3 失败重试与日志别让推送静默消失定时任务最怕的不是失败是失败了没人知道。目标用户是女朋友她只会觉得「今天怎么没发」不会去看日志。所以要主动做两件事。第一件是重试。天气接口和公众号接口都可能偶发超时一次失败就放弃太浪费。给run()外面套一个简单的重试import time def run_with_retry(times: int 3, delay: int 20) - None: last_err None for i in range(times): try: run() return except Exception as e: last_err e print(f第 {i 1} 次失败: {e}) if i times - 1: time.sleep(delay) # 别立刻重试给对方接口一点缓冲 raise RuntimeError(f重试 {times} 次仍失败: {last_err})delay20秒不是拍脑袋。token 获取失败多数是瞬时抖动隔十几秒再试一次基本能过立刻重试反而容易连续撞在同一个错误上。第二件是失败兜底。重试全挂之后如果还有个备选通道邮件、另一个 webhook 机器人发一条「今天的自动推送失败了」给自己。人看到告警还能手动补发一条总比默默断掉强。没有备选通道的话至少在流水线里开邮件通知——失败了收一封邮件也是后悔药。日志本身也要克制。别把secret和完整的access_token打进日志流水线的日志在公开仓库里是对所有人可见的。打印token[:8] ...足够定位问题。5. 避坑与排查几个让我凌晨改代码的坑这些坑的共同点是报错信息极其吝啬你只能靠经验和对照表反推。按现象、原因、解决整理一遍下次遇到能少走两小时弯路。5.1 权限与凭证类问题现象接口返回{errcode: 43004, errmsg: require subscribe}代码逻辑看着没问题。原因收件人 openid 对应的用户没有关注这个公众号或测试号。模板消息的前提是对方是粉丝取消关注之后立刻失效。另一种可能你用的是 A 公众号拿到的 openid却拿它往 B 公众号发。解决让对方重新扫码关注测试号然后在测试号管理页的「用户列表」里重新复制 openid。openid 是跟着「公众号 用户」这一对生成的换号就变不能跨号复用。这个玄学点第一次遇到会懵很久。现象{errcode: 40164, errmsg: invalid ip ... not in whitelist}本地能跑线上就挂。原因正式服务号要求调用接口的 IP 在白名单里而流水线的出口 IP 是动态的今天能跑明天就变。解决要么换测试号无 IP 白名单限制要么上云函数并买固定 IP 出口把那个 IP 加进白名单。别指望给一个 IP 段放行IP 段变动更频繁。现象报40001 invalid credential但 token 明明刚取出来。原因同一个 appid 在多处并发取 token后取的把先取的顶掉了。或者 token 缓存过期时间算得太紧正好卡在边界。解决全局只允许一个地方取 token缓存过期时间设为expires_in - 300。多实例部署时用共享缓存Redis / 数据库键名带上 appid加互斥锁。5.2 消息体与模板类问题现象{errcode: 40037, errmsg: invalid template_id}。原因template_id 抄错了或者模板是另一个公众号的。测试号的模板要自己在测试号页面手动添加添加完才有 id不能用正式号后台的。解决去测试号管理页确认模板列表复制完整 id。顺便看一眼模板里的变量名data的 key 必须和它一字不差包括大小写。现象发送成功返回errcode: 0但手机上收到的消息里某一项是空的。原因data里的 key 和模板变量对不上平台不会报错只是那一项不渲染。这是最阴的一种失败——接口层面完全成功。解决第一次接模板时把data的所有 key 打印出来和后台模板的变量名逐个对一遍。之后每改一次模板都要重新对一次。现象报45002 content size out of limit。原因单个value太长。模板消息对每个变量的长度有限制中文尤其吃长度自定义文案一不小心就超了。解决所有进入data的字符串统一截断到 20 字以内。note[:20]这种写法看着丑但比线上报错好。真要发长文案模板消息不是合适的载体。5.3 定时与环境类问题现象流水线显示绿色成功但消息没收到。原因main.py里 catch 了异常只打印不退出进程返回码 0。解决except分支里必须sys.exit(1)或者干脆不 catch 让它自然抛出。另外去流水线日志里搜msgid有这行才算真的发出去了。现象消息收到了但日期是昨天星期几也不对。原因容器时区是 UTCdatetime.now()拿的是 UTC 时间和北京时间差 8 小时。早上 7 点推送对应 UTC 23 点日期自然退了一天。解决环境变量里加TZ: Asia/Shanghai或者在代码里显式用datetime.now(timezone(timedelta(hours8)))。前者更省事后者更可靠——不依赖运行环境。现象某天开始定时任务突然不跑了日志里什么都没有。原因仓库长期没有提交活动平台自动停用了定时任务或者 cron 表达式写错比如写成 6 段秒级表达式。解决cron 是 5 段分 时 日 月 周别多写。定期往仓库推一次无意义提交比如更新文案可以保活或者干脆换云函数触发器。6. 二次开发把它改成你自己的每日播报跑通之后这个项目真正的价值在于它是个骨架能往任意方向长。我按改动成本从低到高列几条路你可以挑一条。最省事的是换内容源不动架构。天气换成纪念日倒计时、GitHub 今日 trending、基金净值、课程表、待办清单只需要重写fetch_xxx()和build_data()两个函数wechat.py和定时配置一行不动。判断标准很简单只要你的数据源能变成一个扁平字典就能塞进现有的三段式链路。再往上一点是多收件人。WX_OPENID从单个值改成逗号分隔的列表run()里加一层循环每个人可以配不同的城市和文案# 多收件人改造配置结构从字符串变成列表 RECIPIENTS [ {openid: oXXXX1, city: 101010100, note: 今天也要开心}, {openid: oXXXX2, city: 101020100, note: 按时吃饭}, ] def run_all(): token wechat.get_access_token(config.WX_APPID, config.WX_SECRET) for r in RECIPIENTS: # token 提在外面只取一次 w weather.fetch_weather(config.WEATHER_KEY, r[city]) data template.build_data(r[city], w, r[note]) wechat.send_template(token, r[openid], config.WX_TEMPLATE_ID, data)关键点是 token 提到循环外面。放循环里的话第二个收件人开始就会因为 token 被顶掉而报40001这个坑我在第一次做多人的时候结结实实踩过。如果想让它更像「人发的」就别用模板消息改用客服消息接口把内容拼成一段自然语言文本发出去。限制是客服消息有 48 小时会话窗口用户必须在两天内和公众号有过交互——所以这条路更适合「回复用户提问」的场景不适合每日无人值守推送。改造方向改动文件主要风险换数据源weather.pytemplate.py字段长度超限多收件人config.pymain.pytoken 被重复获取顶掉换推送渠道wechat.py新渠道的鉴权模型不同改常驻服务全部加缓存层token 并发、进程重启丢缓存最后说个我自己的习惯。这套代码我从测试号搬到正式服务号的时候因为 IP 白名单、模板迁移、openid 全部重新拿几乎每改一处都要重跑一遍完整流程。从那以后我每次改动前都会先用workflow_dispatch手动触发一次确认「取数 → token → 发送」三段日志都打出来了再去改定时表达式。手动触发按钮就是这套东西的后悔药比任何调试技巧都实在。如果你也想给对象做这么一条每天准点到的消息代码就这些照着五章的顺序搭一遍一晚上能跑通。希望帮到你。本文还有配套的精品资源点击获取