☰
微信天气推送自动化:测试号+模板消息+定时任务全链路
2026/10/14 12:24:37 网站建设 项目流程

简介:面向有一点编程基础、希望用代码为伴侣制造小惊喜的开发者,这套教程完整演示了微信公众号每日自动推送天气与纪念日通知的制作流程。核心依托 GitHub Actions 实现云端每日定时运行,申请天行和风天气 API 后,即可在每天早上向指定微信号发送天气、星座或纪念日提醒,整体流程简单易上手。压缩包内提供成品、源码和消息模板,并有最新版、简化版、单独星座、星座加天气以及 PHP 版等多套版本,读者可按实际需要直接选用或改造。包体共 5 个文件、仅 5KB,其中 2 个 txt 负责消息模板与依赖配置,Python 主脚本实现推送逻辑,yml 定义 GitHub Actions 自动化流程,md 文档说明部署方式,结构十分精简。目前已有 7690 人学习下载,对想实践 API 调用、GitHub Actions 自动化任务和消息推送的入门者,是一份轻量但完整的参考。

1. 微信每日天气推送:先解决“每天早上被追问穿什么”这件事

你有没有过这种经历:每天早上还没起床,就被追问“今天冷不冷”“要不要带伞”“穿长袖还是短袖”。问一次两次还能耐心查,天天问就有点顶不住。前阵子有个朋友(某开发者)跟我吐槽这事,我给他搭了一套微信公众号天气通知的自动化链路:每天固定时间用脚本拉天气数据,组装成一条带日期、天气、气温、湿度的提醒消息,通过微信公众号模板消息接口推给他女朋友,全程不需要人肉操作。这套东西不挑技术背景,一个测试号加一台能跑定时任务的机器或云函数就能落地,重点是消息是真实推送到微信里的,跟App推送完全两种体验。这篇就把整条链路怎么搭、参数怎么配、容易踩哪些坑写清楚,适合想给家人或自己做个定时天气助手的人直接抄作业。

2. 链路设计与账号准备:测试号、模板消息、天气源三方怎么配合

2.1 先分清三件事:推送渠道、定时触发、数据来源

很多人一上来就想写代码,结果卡在“消息怎么从微信发出去”这一步。其实整个每日推送拆开就三个环节:谁负责定时叫醒、谁去拿天气、谁把消息塞进微信。定时这一环最简单,Linux 的 cron 或者云函数平台的定时触发器都行;数据源就是天气接口,返回 JSON,我们只需要关心温度和天气现象字段;最容易被绕进去的是微信侧,因为不是随便一个公众号都能给用户推模板消息。

微信这边需要分清楚“订阅号”和“服务号”的差别。个人主体注册的公众号默认是订阅号,订阅号没有模板消息接口权限,服务号才有。但服务号个人没法注册主体,这就出现了一个典型困境。实际走通的做法是:用微信公众平台提供的“测试号”来仿真服务号环境。测试号有完整的接口权限,能申请模板消息,也能真实推送给关注测试号的粉丝。对你来说,女朋友关注的是一个测试号,收到的消息展示效果和正式服务号几乎一样,只是入口在测试号的二维码里。后面讲部署时,正式服务号可以平滑替换,但第一版先别纠结主体问题。

另一个容易被忽略的点是:你的定时脚本必须运行在一个微信能回调到的环境里吗?不需要。模板消息是主动推送,脚本只需要拿着 access_token 调微信接口即可,不要求公网回调地址。所以部署环境很宽松,本地电脑配合定时任务都能跑,只是不建议关机。

2.2 为什么先拿测试号练手,而不是直接上正式服务号

我见过不少人一上来就注册公众号,折腾了一周连模板消息的入口都没找到。用测试号的最大好处是跳过资质门槛。去微信公众平台官网找到“测试号”申请页,扫码就能拿到一对 appID 和 appsecret,这两个字符串就是后续调接口的凭证。

再说权限差异。正式服务号的模板消息接口需要先在公众号后台添加模板,而且模板库按行业分类,个人开发者可选行业有限,有时会出现“想要的模板不在这类”的情况。测试号的模板库是全量开放的,什么“每日天气提醒”“每日问候”这类模板都有得选,还可以自定义添加模板,这在初期调通链路时非常省事。

还有一个现实原因:openid 是跟着公众号走的。同一用户在不同公众号下的 openid 不一样。测试号的粉丝列表和正式号是两套,第一版先用测试号把代码跑通,确认消息能送达,再去申请正式服务号替换参数,风险小得多。替换成本基本就是改两个环境变量和模板ID的事。

2.3 天气源选型:免费接口够用吗,字段和稳定性怎么取舍

天气数据这一环同样有讲究。市面上能用的接口大概分三类:地图平台类天气接口、专业天气开发者平台、还有聚合类的免费接口。选型主要看三个维度:每日请求配额、返回字段是否满足需求、城市编码体系是否容易拿。

先说配额。每天只推一次,一次只查一个城市,理论上什么接口都够用。但很多免费接口会按 QPS 或按日请求数限制,比如有的限制每天只能调 100 次。如果脚本里每次发送前都现场拉一次天气,被限制的后果就是消息推送出去,但天气内容是空的,这个坑在后面第 5 章会细讲。稳妥做法是每天只拉一次,把结果缓存住,所有后续推送、重试都读缓存。

字段方面,主脚本只需要当前天气现象、最高温、最低温、风力这四类,新手容易踩的坑是拿到接口文档不去看返回字段格式,直接把整个 JSON 塞进消息里,结果模板里显示一长串编码。正确做法是用接口返回里的具体字段名去映射模板占位符,下面 3.1 会给出代码。

城市编码建议直接用 adcode(区域代码),不要用城市名传参。城市名有重名问题,比如“朝阳”在辽宁和北京都有,接口按地名解析时容易翻车。adcode 是六位数字,全国唯一,配置时多填一行参数,换来的是稳定。

提示:正式上线前先确认天气接口的免费阈值。有的接口首月免费,次月就要绑定支付方式,这类接口适合本地试用,不适合做长期每日任务。

3. 核心代码实现:Node.js 把“天气 JSON”拼成“模板消息 payload”

3.1 天气请求封装:入参、返回字段与异常兜底

我习惯用 Node.js 写这个脚本,因为运行时轻,云函数也原生支持。下面是一个典型的天气请求封装,用了 Node 18 以后内置的 fetch,不需要额外引第三方库。不同的地图开放平台接口签名大同小异,核心参数一般是 key、city、extensions 这三个。

const fs = require('fs'); const path = require('path'); // 天气接口地址换成你自己申请的,注意 key 不要硬编码在代码里 const WEATHER_URL = 'https://your-weather-api.example/weather/weatherInfo'; const WEATHER_KEY = process.env.WEATHER_KEY || '填你的key'; async function getWeather(adcode) { const url = `${WEATHER_URL}?key=${WEATHER_KEY}&city=${adcode}&extensions=base`; const resp = await fetch(url); const data = await resp.json(); if (data.status !== '1' || !data.lives || data.lives.length === 0) { // 接口返回异常时抛错,让上层决定要不要重试 throw new Error(`weather api error: ${JSON.stringify(data)}`); } const live = data.lives[0]; return { city: live.city, weather: live.weather, temperature: live.temperature, high: live.temperature_high, low: live.temperature_low, wind: live.winddirection + live.windpower, humidity: live.humidity, reportTime: live.reporttime, }; } module.exports = { getWeather };

这里extensions=base表示只回传实时天气和当天预报,如果你传all,接口会返回未来几天的预报数组,数据量翻好几倍,但对每日推送来说绝大部分用不上。temperature_high和temperature_low有些接口只在预报接口里返回,实时接口只有temperature,如果拉到undefined,模板消息里就会显示undefined。遇到这种情况,检查一下接口文档里你用的到底是“实况天气”还是“天气预报”,实况天气一般不含最高最低温。

3.2 access_token 的获取与 7000 秒缓存

调用微信模板消息接口必须先拿 access_token,这个 token 有效期是 7200 秒。微信官方文档明确要求:公众号的 access_token 是全局唯一凭证,且调用频率有限。很多人图省事每次发送都现取一次,结果高频触发时拿到的 token 互相覆盖,反而导致 40001 错误。

const TOKEN_FILE = path.join(__dirname, 'token.json'); const APPID = process.env.WX_APPID; const SECRET = process.env.WX_SECRET; const TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token'; async function getAccessToken() { // 先读本地缓存,未过期直接用,过期再刷新 if (fs.existsSync(TOKEN_FILE)) { const cached = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8')); if (cached.expire_time > Date.now()) { return cached.access_token; } } const url = `${TOKEN_URL}?grant_type=client_credential&appid=${APPID}&secret=${SECRET}`; const resp = await fetch(url); const data = await resp.json(); if (data.errcode) { throw new Error(`token error: ${data.errcode} ${data.errmsg}`); } // 这里故意把过期时间设成 7000 秒而不是 7200 秒,留 200 秒余量 const tokenObj = { access_token: data.access_token, expire_time: Date.now() + 7000 * 1000, }; fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenObj), 'utf8'); return tokenObj.access_token; } module.exports = { getAccessToken };

关键点有两个:一是缓存文件必须跟天气预报缓存分开,避免偶发写冲突;二是刷新条件不要用“等于过期”,而要用“提前 200 秒刷新”。这个习惯能省掉很多凌晨推送失败的问题。如果你部署在云函数上,注意云函数的本地文件系统是临时的,实例释放后 token 缓存会丢,这个问题第 5 章会给出替代方案。

3.3 组装模板消息并发送:先打日志,后触达用户

微信模板消息的请求体有固定结构:touser 是接收者的 openid,template_id 是你申请的模板 ID,data 里的每个 key 必须严格对应用户在模板里看到的关键词字段。下面是发送函数。

const SEND_URL = 'https://api.weixin.qq.com/cgi-bin/message/template/send'; async function sendTemplateMessage(openid, templateId, weatherInfo) { const token = await getAccessToken(); const payload = { touser: openid, template_id: templateId, data: { date: { value: getToday() }, weather: { value: weatherInfo.weather }, temperature: { value: `${weatherInfo.low}℃ ~ ${weatherInfo.high}℃` }, wind: { value: weatherInfo.wind }, humidity: { value: weatherInfo.humidity + '%' }, tips: { value: buildTips(weatherInfo) }, }, }; // 发送前把 payload 打到日志,方便排查字段格式问题 console.log('[send]', JSON.stringify(payload)); const resp = await fetch(SEND_URL + '?access_token=' + token, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const result = await resp.json(); if (result.errcode !== 0) { throw new Error(`send failed: ${result.errcode} ${result.errmsg}`); } return result; }

组装 payload 这里最容易翻车的是 tips 字段。tips 是提醒语,比如“今天有雨记得带伞”“温差大注意保暖”,它需要你根据天气现象做一个简单的规则判断,而不是直接把天气原文再重复一遍。buildTips 函数里一般会判断 weather 字段是否包含“雨”“雪”“霾”等关键字,拼出对应提醒。另外要注意模板消息的 data 值不能超过模板定义的字数限制,比如字符型字段一般限制 20 字以内,超长会直接报 43004,这块放第 4 章展开。

4. 模板消息接入细节:模板申请、字段映射与发送限制

4.1 模板申请和关键词选择:越低配越不容易踩格式坑

在测试号后台添加模板时,你会看到一个模板库,里面有大量现成模板可以选用。我的建议是选字段最少的那一个,而不是看起来最丰富的那个。比如“每日天气提醒”模板可能只有日期、天气、气温三个字段,这就够了。字段越少,你组装 payload 时需要考虑的格式越少,排查 bug 的维度也就越少。

选完模板后,重点看模板库里每个字段的类型标识。微信模板消息的字段类型有date、time、thing、number、character_string、symbol等。日期字段要求标准格式,比如“2019年10月1日”或“10月1日”,不能带随意符号;数字字段要求数值类型;thing 字段限 20 字以内且不能有特殊符号。如果你选的模板里带了“备注”这种 thing 类型字段,拼接时就得特别小心,下雨刮风这种提醒语很容易超过 20 字。

提示:模板 ID 是跟着模板走的,添加后不会变。但如果你在测试号里删掉模板,再用同一个模板名重新添加,拿到的 template_id 会变化,已经写死在配置里的旧 ID 会报“模板无效”。

4.2 字段映射:模板占位符和实际数据的对应关系

字段映射是这份代码里最枯燥也最关键的部分。模板里显示的每一个关键词,对应到 data 里的 key 必须一字不差。比如模板里写的是“日期”,data 里的 key 就要叫date;写的是“天气”,key 就要叫weather。如果你自定义模板时把字段起名叫“today_weather”,那代码里的 key 也要换成today_weather,不能想当然用英文名代替。

一般字段映射表长这样:

模板字段代码变量类型示例值
日期datedate4月18日
天气weatherstring多云
气温tempstring12℃ ~ 22℃
风力windstring东南风3级
湿度humiditynumber65%
提醒tipsthing昼夜温差大,带件外套

其中湿度字段注意,如果模板里限定的是 number 类型,value 就写数字 65,不要写“65%”,否则格式校验报错。气温这种带区间和单位的内容,模板类型一般选 string(有的模板叫 character_string),能接受中文和数字混排。天气现象字段最省心,直接用接口返回的中文字符串就好,千万别在代码里做英文到中文的映射表,多此一举还容易漏。

4.3 发送限制与错误码:43004、45009 这些数字意味着什么

模板消息能推,但不能无限推。微信对用户侧有“每个用户每个模板每天最多接收一次”的底层限制,对公众号侧有每日模板消息量配额。对每日一推的场景,前者基本碰不到,只要你一天只发一封,或者即使发了多封,同一用户同一模板第二次也会被拦。后者要看你的公众号类型,测试号配额很高,个人用绰绰有余。

错误码是最直接的排错信号。40001 是 access_token 无效或过期,优先检查缓存逻辑;40003 是 openid 错误,一般是把正式号的 openid 传给了测试号;43004 是模板消息参数不合法,多半是字段值类型不对、超长或缺了必填字段;45009 是接口调用超过频率限制,常见于调试阶段频繁点击发送按钮。看到 45009 别慌,等五分钟再试,同时把日志里上次成功的 payload 翻出来对比,确认不是字段问题。

遇到 43004 时,最快的排查方式是写一个最小 payload,只保留模板里必需字段,值都写字母 a,发送成功后再逐个字段替换成真实数据。这样能把问题字段精确锁定,比对着文档猜效率高得多。

5. 定时任务与避坑:cron 和云函数两种部署方式及五条排查记录

5.1 云主机上跑 cron:守护进程与日志切割

如果你手里有一台云主机,或者本地电脑长期不关机,用 cron 是最直接的方案。先确认 Node 脚本能通过node send.js手动跑通,再把它挂到 crontab 里。编辑定时任务用crontab -e,注意 Node 要用绝对路径,脚本路径也要用绝对路径,cron 的环境变量比交互式 shell 少很多。

# 每天 8 点 5 分执行,日志追加到文件,避免 cron 默认的邮件通知 5 8 * * * /usr/bin/node /home/user/weather/send.js >> /home/user/weather/logs/send.log 2>&1 # 每周一到周五 8 点 5 分执行(周末不想打扰可以这样写) 5 8 * * 1-5 /usr/bin/node /home/user/weather/send.js >> /home/user/weather/logs/send.log 2>&1

写完后用crontab -l确认任务在列表里。有个很容易忽视的点:cron 不会自动加载你 shell 里的 PATH 和 NODE_ENV,如果脚本里用到了 environment 变量(比如 WEATHER_KEY),建议在脚本顶部写死读取规则,或者把密钥写进项目里的 .env 文件并用 dotenv 加载。另外脚本必须自带异常捕获,如果天气接口挂了,脚本抛错退出,cron 不会帮你重跑,所以至少要在脚本入口处 catch 一层。

日志文件会一直增长,时间长了占满磁盘脚本就跑不动了。可以在 crontab 里加一条 logrotate 规则,或者简单粗暴地每周清一次。个人用的话,周日凌晨把旧日志压缩归档就够了。

# 每周日凌晨 0 点 30 分,把上周日志归档并清空新日志起点 30 0 * * 0 mv /home/user/weather/logs/send.log /home/user/weather/logs/send_$(date -d "last week" +\%Y\%m\%d).log

5.2 云函数定时触发器:免服务器运维,但注意时区

不想维护云主机的话,云函数的定时触发器更省心。它不需要你关心 cron 进程是不是活着,平台会保证触发。但新手最容易在这里翻车的是时区问题:很多云函数平台的定时表达式默认使用 UTC 时区。如果你想在每天早上 8 点(北京时间)推送,直接填0 0 8 * * *得到的是 UTC 早上 8 点,也就是北京时间下午 4 点,完全打乱节奏。

云函数入口代码的适配很关键。你本地脚本是直接node send.js跑,云函数则是导出固定的入口方法,平台用事件参数调用它。

// 云函数入口:把 send 逻辑包一层 const { getWeather } = require('./weather'); const { getAccessToken } = require('./token'); const { sendTemplateMessage } = require('./sender'); exports.main_handler = async (event, context) => { try { const config = JSON.parse(process.env.PUSH_CONFIG); const weather = await getWeather(config.adcode); const result = await sendTemplateMessage(config.openid, config.templateId, weather); console.log('push success:', JSON.stringify(result)); } catch (e) { // 把错误打出来,云函数平台的日志里能看到完整堆栈 console.error('push failed:', e.message); throw e; // 触发平台重试 } };

在云函数控制台配置环境变量时,把 appid、secret、openid、adcode、template_id 全部放进去,代码里用process.env读取,这样换人换城市不用改代码。定时触发器配置时注意看控制台有没有时区选项,一般会让你选“北京时间”或者“Asia/Shanghai”,没有就直接在 cron 表达式里把小时数减 8 再填,比如北京 8 点就填0 0 0 * * *,并在注释里写明是补偿时区后的结果。

云函数实例的临时文件系统可能导致 token 缓存失效。因为实例可能被回收重建,文件缓存读不到,每次触发都会重新请求 token,频率不高倒也没事。更好的做法是买一个简版 Redis?个人场景不值得。直接用第 3 章节的代码,每天请求一次 token 接口完全在配额内,不用过度设计。

5.3 常见问题与避坑:现象、原因、解决的五个真实记录

第一个坑是消息发送成功但女朋友收不到。现象:脚本日志打印 errcode 为 0,接口返回成功,但对方微信里没有任何消息。原因:她关注的不是同一个公众号,或者说她扫的二维码和你代码里配置的 appid 不是一套。常见于你先用自己微信调试,后来让她扫了另一个入口。解决:到测试号管理页重新生成二维码,让她重新扫码,然后把管理页里显示的 openid 更新到配置文件中,再发一次测试消息。

第二个坑是凌晨推送偶尔失败。现象:定时任务每天 8 点 5 分触发,但偶尔日志里显示 token error。原因:access_token 文件缓存被多个并发任务同时读写,比如你手动跑了测试脚本,又开了定时任务,两个进程同时刷新 token,后写的覆盖了先写的,先写的就失效了。解决:给 token 文件加进程锁,或者更简单点,把手动调试和定时任务分开在不同目录跑,避免共享同一个 token.json。

第三个坑是天气接口返回数据但温度是空的。现象:推送成功了,消息里气温字段显示 undefined。原因:用了实况天气接口,该接口根本没有最高最低温度字段。解决:把天气接口切换成预报接口,或者像 3.1 代码里那样,在映射前先判断字段是否存在,不存在就用实时温度兜底。

第四个坑是模板消息报 43004 格式错误。现象:日志里报template message data format error。原因:某字段的类型和模板定义不一致,最常见的湿度字段填了字符串。解决:对照 4.2 的字段映射表,把 value 转成对应类型。字符串类型检查有没有超长或者带着奇怪的换行符。

第五个坑是云函数定时任务不触发。现象:控制台显示函数配置正常,但到了时间没有任何日志输出。原因:定时触发器创建时填的 cron 表达式和函数没绑定,或者时区配置错了,表达式在 UTC 模式下完全错位。解决:先在控制台触发一次测试事件,确认函数本身能跑通;再去触发器配置里看下一条执行时间预览,多数平台会直接展示下次触发时间,对比一下是不是你要的 8 点。

6. 再进一步:多城市、失败重发与一条消息多说几件事

6.1 多城市支持:一份配置,多个 openid

如果家里不只一个人在不同城市,可以把它扩展成配置数组,循环发送。下面这段把配置抽成了一个 JSON 文件,每个元素包含城市 adcode 和对应收件人的 openid。

const pushList = [ { name: '小A', adcode: '110000', openid: 'oXXXX1' }, { name: '小B', adcode: '310000', openid: 'oXXXX2' }, ]; for (const item of pushList) { const wx = await getWeather(item.adcode); await sendTemplateMessage(item.openid, templateId, wx); }

注意循环里每次调用sendTemplateMessage都会复用同一个 access_token 缓存,不会触发重复刷新。如果两个城市隔得很远,天气模板里最好加一个“城市”字段,避免收件人不知道这条消息说的是哪里的天气。

6.2 失败自动重试:指数退避与结果通知

网络波动或微信接口偶发超时是常态,但定时推送失败一次就意味着当天消息漏了。常见做法是保留一个重试队列,发送失败后等 1 分钟、3 分钟、10 分钟各重试一次,三次都不成功就把错误写入单独的文件,方便事后补发。

async function sendWithRetry(payload, retries = 3) { let lastErr; for (let i = 0; i < retries; i++) { try { return await sendTemplateMessage(payload); } catch (e) { lastErr = e; await new Promise((r) => setTimeout(r, 1000 * Math.pow(3, i))); } } fs.appendFileSync('failed.log', `${new Date().toISOString()} ${lastErr.message}\n`); }

6.3 把天气消息做成“早晚报”:历史数据攒下来还能用

每天推送的数据如果顺手追加到一个历史文件里,攒一个月还能生成简单的周报或月报——比如“过去一周有 4 天下雨”“最高气温出现在周三”。我在实际项目里就是每天把接口返回的 weatherInfo 原样追加到history.jsonl文件,月末跑一个小脚本统计,给女朋友推一条月度天气回顾,效果比千篇一律的每日提醒更好。从那以后我每次部署这个天气推送工程,都会强制走一遍“先手动触发、再观察一天、再看日志确认送达”的完整测试流程,不然总担心某个环节半夜悄悄断了。希望帮到你。

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

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

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

立即咨询