简介近期短视频平台带火了‘给对象做天气推送’的公众号玩法这套教程专门面向想亲手为伴侣制作每日推送的读者只要有最基础的代码阅读能力就能按步骤完成部署。资源包共5个文件压缩后仅5KB内容非常紧凑Python主程序负责抓取天气和纪念日信息配置文件用来填入公众号Token与API密钥工作流配置让GitHub在每天指定时间自动运行依赖清单确保环境完整说明文档则从申请API到部署上线给出了全程指引。这套教程发布至今已有7688人学习使用热度在同类型资源中表现亮眼可见这种兼具实用与浪漫的小项目很受欢迎。借助包内的源码与消息模板读者只需要将密钥替换成自己的信息就能生成一个免本地运行的每日推送服务即使之前没接触过GitHub Actions也可以参照说明文档的逐步讲解完成配置。整个方案轻量、直接既能定时发送天气提醒也能加入纪念日等个性化内容很适合作为情人节、生日等时机的用心惊喜同时为后续二次开发保留了清晰入口。1. 微信公众号每日天气推送把一次 API 调用变成每天早上的问候做这个项目前我一直以为公众号推送要折腾域名回调、消息验签那一套。真正跑通后发现核心就一句话每天早上用天气 API 拿数据塞进微信模板消息推给指定的人。不需要认证服务号不需要备案域名一台能跑 Python 的服务器或者树莓派就够核心脚本不到 80 行剩下的大头全在配置。这套方案用的是微信测试号加免费天气接口我实际跑了大半年没断过。它解决的是「每天定时给女朋友或家人推一条真实天气」的具体诉求顺带把公众号模板消息的完整调用链演示了一遍。适合刚开始接触公众号开发的新手也适合想抄一份能改参数的定时推送脚本的从业者。下面从选型开始一步步拆到可以照抄的代码和避坑经验。2. 推送链路怎么搭测试号、天气 API 和模板消息三件套2.1 为什么是测试号而不是认证服务号公众号做消息推送最大的门槛在账号类型。个人主体能注册的只有订阅号而订阅号的接口权限表里没有模板消息模板消息是服务号的主场但服务号注册需要企业主体个人拿不到认证还要每年 300 元。微信官方给开发者留了一条免费通道公众平台测试号。用个人微信扫码登录立刻就有独立的 appID 和 appsecretuser/get、template/send这些接口全部能调不需要绑定域名不需要走认证流程。测试号和正式号的区别主要有三点粉丝必须通过页面上的测试二维码扫码关注关注量有上限模板消息只能从测试模板里选不能申请正式模板库里的行业模板没有自定义菜单、客服消息这些外围能力。对「每天给一个人推一条天气」的场景前两点完全无所谓。我把它当生产环境用了一年推送成功率和正式接口没有区别速度也一样。一个容易忽略的点测试号页面是用个人微信扫码登录的上面显示 appID 和 appsecret。别以为记住了路径就永远能回来——微信账号状态异常、换绑手机号都可能让你进不去调试页密钥取不回来就得重新生成所有配置跟着重来一遍。所以第一步就把两个密钥抄到本地或者写进服务器的环境变量文件里备份。这一条在后面避坑章还会再提。2.2 天气数据源和风还是心知推送内容要真实、定时、还不能断天气数据源的稳定性比字段丰富程度更重要。国内免费接口里最常见的是和风天气和心知天气我两家都注册过最终固定在和风的/v7/weather/3d接口上。先看一张对比表再解释选择理由数据源免费额度定位方式常见坑和风天气开发版每日约 1000 次城市 ID 或经纬度返回字段名多textDay/textNight 容易记混心知天气免费版每日次数较少城市拼音beijing免费版偶有数据延迟字段较精简选和风有三个理由。第一3 天预报接口一次请求就把今天、明天、后天都拉回来了早上推送时能顺手预告「明天降温」比单日报更有用。第二定位参数用城市 ID 而不是拼音拼音方案在多音字城市上容易翻车比如重庆有人写 chongqing 有人写 zhongqing而城市 ID 是纯数字没有这种歧义。第三免费版额度 1000 次/天每天跑一次脚本只消耗一次配额余量足够测试和误操作。注册和风账号是标准套路进控制台创建项目选「免费开发版」生成一串 32 位左右的 API Key项目详情页里能看到 Key复制后放到配置区。定位用的城市 ID 在和风官网「城市列表」里搜城市名获取北京是 101010100上海是 101020100直接查自己的城市就行。注意城市 ID 不是行政区划代码别拿 110000 那种六位行政区划去填 location 参数接口不认。2.3 手动拉通接口curl 验证和 openid 定位写脚本之前我习惯先把两个接口用 curl 手动调通。这一步能把「接口配置错」和「代码写错」隔离开后面写代码时所有报错都能直接归因到代码层。第一个命令验证天气接口curl https://devapi.qweather.com/v7/weather/3d?location101010100key你的APIKey正常返回的 JSON 里code是200daily数组里有三条记录分别对应今天、明天、后天。如果返回401或403多半是 Key 复制少了字符或者项目刚创建还没生效等一分钟再试。这里注意看daily[0].fxDate是不是今天的日期如果对不上说明这个城市 ID 对应的不是你想推的城市换 ID 重试。第二个命令验证微信接口拿一个临时 access_tokencurl https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappid你的appIDsecret你的appsecret返回 JSON 里出现access_token和expires_in: 7200就说明 appID、appsecret 正确报errcode: 40013说明 appID 写错了报errcode: 40125说明 appsecret 写错了逐个核对粘贴即可。token 拉到手后顺手把关注者列表拉出来因为测试号后台「体验者」列表只显示微信号openid 要通过接口取curl https://api.weixin.qq.com/cgi-bin/user/get?access_token刚拿到的token返回的data.openid数组里就是所有关注者的 openid一条一个。测试号关注者少逐条比对微信显示名就能确认哪条是女朋友的复制进配置里。最后一步在测试号页面往下拉找到「模板消息接口」区域点「新增测试模板」从模板库选一个天气模板也可以直接自定义内容。字段名自己起但保存后脚本传参必须和它一致模板 ID 那串字符要抄下来它就是后面脚本里TEMPLATE_ID的值。到这里账号凭证、天气 Key、openid、模板 ID 四样东西齐全手动链路已经打通下一章把它们拼成真正的推送消息。3. 把天气数据装进模板消息字段映射与拼装3.1 模板消息结构字段名必须逐字对应微信公众号模板消息本质上是一段带占位符的文本。在测试号后台点「新增测试模板」编辑框里写的就是将来用户收到的样式占位符写法是{{字段名.DATA}}。发消息时脚本往data里传值微信把占位符替换成对应内容。比如我一直用的模板城市{{city.DATA}} 日期{{date.DATA}} 天气{{weather.DATA}} 气温{{temp.DATA}}风力{{wind.DATA}} 温馨提示{{note.DATA}}字段名city、date、weather、temp、wind、note是创建模板时自己起的但一旦保存脚本传参就必须和它一字不差大小写敏感。{{city.DATA}}和脚本里的City都不匹配。这是全项目翻车率最高的地方避坑章第一条就是它。字段名建议全小写英文单词复合词用下划线连接比如temp_max别用中文或缩写后期维护省心。推送接口是message/template/send一条完整请求的 JSON 结构如下{ touser: oXXXX-XXXX-XXXX, template_id: 模板ID, data: { city: {value: 北京}, date: {value: 2025-06-15}, weather: {value: 晴}, temp: {value: 18~28℃}, wind: {value: 东南风 2级}, note: {value: 今天阳光不错} } }touser是接收者的 openidtemplate_id是后台模板列表里那串 IDdata里每个 key 必须命中模板字段。value 是纯文本单字段最长约 20 个汉字超长会截断显示color 是可选配置网上教程喜欢把温度标红实际效果一般默认黑色更干净。我给所有字段都省掉了 color微信按默认样式渲染换行和缩进跟随模板原文。3.2 天气 JSON 解析的几个字段陷阱和风 3d 接口返回的daily数组里每一项包含fxDate日期、tempMax最高温、tempMin最低温、textDay白天天气、textNight夜间天气、windDirDay白天风向、windScaleDay风力等级。有几个字段非常容易记混我列一下取值习惯推送用textDay表示当天天气接口文档里没有text这种字段别凭直觉写。温度先转int()再拼字符串。tempMax、tempMin是字符串直接拼会得到18~28这种带引号的效果转成整数后拼成18~28℃最自然。windDirDay和windScaleDay免费版偶尔为空取到空值就不要塞进模板否则消息里会出现空白字段用or 微风兜底。写解析函数时我的习惯是先校验code再取数据而不是直接写resp[daily][0]。如果接口限流返回错误结构直接下标取值会抛 KeyError日志里只有一行难懂的 traceback加了 code 校验后错误信息至少能看懂是接口侧的问题import requests def get_weather_today(location_id: str, key: str) - dict: 拉取今日天气摘要失败时抛异常而不是静默返回空 dict url https://devapi.qweather.com/v7/weather/3d params {location: location_id, key: key} resp requests.get(url, paramsparams, timeout10).json() if resp.get(code) ! 200: raise RuntimeError(f天气接口返回异常: {resp}) today resp[daily][0] return { date: today[fxDate], weather: today[textDay], temp_min: int(today[tempMin]), temp_max: int(today[tempMax]), wind: f{today[windDirDay]} {today[windScaleDay]}级, }这段代码的逻辑分三层先判 code 拦截接口异常再取daily[0]作为今天的数据最后做类型转换和字段拼接。timeout10是刻意加的免费接口偶尔会挂起没有超时的话整个脚本会被一次慢请求卡死cron 日志里看起来就像什么都没发生过。注意daily数组的下标含义固定[0] 今天、[1] 明天、[2] 后天。想推「明日天气」把下标改成 1 就行其余不用动。3.3 完整推送脚本三个函数串起整条链路把前面几节拼起来就是一个独立脚本不依赖任何框架。三个函数分别负责取 token、取天气、发消息任何一步出错日志里能一眼定位。完整代码如下import json import requests from datetime import datetime APPID wx... # 测试号 appID APPSECRET ... # 测试号 appsecret TEMPLATE_ID 模板ID # 测试模板的 template_id OPENID oXXXX-XXXX-XXXX # 关注者 openid QWEATHER_KEY ... # 和风 API Key LOCATION_ID 101010100 # 北京城市 ID def get_access_token() - str: url https://api.weixin.qq.com/cgi-bin/token params { grant_type: client_credential, appid: APPID, secret: APPSECRET, } resp requests.get(url, paramsparams, timeout10).json() if access_token not in resp: raise RuntimeError(f获取 token 失败: {resp}) return resp[access_token] def send_template_message(token: str, weather: dict) - dict: url https://api.weixin.qq.com/cgi-bin/message/template/send payload { touser: OPENID, template_id: TEMPLATE_ID, data: { city: {value: 北京}, date: {value: weather[date]}, weather: {value: weather[weather]}, temp: {value: f{weather[temp_min]}~{weather[temp_max]}℃}, wind: {value: weather[wind]}, note: {value: 早安记得看天气再出门}, }, } resp requests.post(url, jsonpayload, timeout10).json() return resp if __name__ __main__: token get_access_token() weather get_weather_today(LOCATION_ID, QWEATHER_KEY) result send_template_message(token, weather) print(datetime.now().strftime(%Y-%m-%d %H:%M:%S), json.dumps(result, ensure_asciiFalse))主流程只有三行拿 token取天气发消息。脚本每次运行都实时获取新 token刻意不做缓存就为了避开两小时过期问题。网上很多教程教你先 curl 拿 token 再粘进代码第二天必报 40001因为 token 早失效了。每次请求 token 的时间成本是一次普通 HTTP GET对每天跑一次的任务完全不是负担。参数替换时重点关注四样APPID和APPSECRET在测试号页面抄TEMPLATE_ID在测试模板列表里复制OPENID用 2.3 节user/get接口查出来的那串LOCATION_ID换成目标城市 ID。四个都替换后手动执行一次女朋友微信如果收到消息手动链路就算闭环了后面只是让它每天自动跑。4. 定时触发与部署cron、日志和失败重试手动跑通只完成了一半另一半是让脚本每天自动执行。Linux 服务器上最普适的方案是 cron不需要 systemd 服务几行配置解决。4.1 cron 定时绝对路径和时区编辑当前用户的定时任务crontab -e首次执行会让你选编辑器选 nano 或 vim 都可以进去后加一行0 7 * * * cd /home/user/weather-push /usr/bin/python3 push_weather.py weather.log 21这一行拆开看0 7 * * *是时间字段依次是分钟、小时、日期、月份、星期含义是每天 7:00 执行cd /home/user/weather-push先把工作目录切到脚本所在目录避免脚本里用相对路径读取文件时报错/usr/bin/python3用绝对路径指定解释器cron 的环境变量比登录 shell 精简得多不写绝对路径会出现「手动跑没问题定时任务就是不执行」的玄学最后的 weather.log 21把标准输出和标准错误都追加进日志出问题时有迹可查。时区是第一次部署最容易踩的坑。服务器默认时区如果是 UTCcron 的 7 点就是北京时间的 15 点女朋友下午才收到「早安天气」数据没错时间全错排查半天找不到原因。两个解决办法我更推荐第二个# 方案一在 crontab 顶部声明时区只影响 cron 本身 CRON_TZAsia/Shanghai 0 7 * * * cd /home/user/weather-push /usr/bin/python3 push_weather.py weather.log 21 # 方案二改系统时区脚本里的 datetime.now() 也跟着变 timedatectl set-timezone Asia/Shanghai date # 确认输出是 CST 而不是 UTC方案二的优势在于脚本里datetime.now()的时间戳和 cron 的调度时间基于同一个时钟日志时间看起来更顺。改完时区后用crontab -l确认任务真的写进去了再手动执行一次脚本确认日志文件可写、路径都正确之后就可以等定时点位。4.2 日志与重试错误码一眼定位问题脚本扔进 cron 后唯一的信息出口就是weather.log。如果日志只 print 成功结果出问题时排查看不到任何线索。我在主流程里加了一层失败日志和有限重试import time from datetime import datetime if __name__ __main__: token get_access_token() weather get_weather_today(LOCATION_ID, QWEATHER_KEY) result send_template_message(token, weather) if result.get(errcode, 0) ! 0: print(f推送失败: {result}) if result[errcode] 40001: token get_access_token() time.sleep(1) result send_template_message(token, weather) print(datetime.now().strftime(%Y-%m-%d %H:%M:%S), json.dumps(result, ensure_asciiFalse))逻辑很简单微信接口成功时返回errcode: 0非 0 即失败。这里只对 40001 重试因为 40001 是 access_token 过期或失效重新获取后重发一次大概率成功而 47003模板字段不匹配、40003openid 不合法这类是代码 bug重试一百次也没用反而把日志刷爆。time.sleep(1)是为了避开接口频率限制属于习惯使然。验证 cron 是否生效有个很实用的办法把时间字段临时改成下一分钟比如当前 14:30就写29 14 * * *等一分钟看日志有没有新输出确认没问题再改回0 7 * * *。这一步比等一个晚上确认快得多我每次部署新机器都会走一遍。4.3 部署边界超时、权限和 API 限额免费服务器或树莓派上长期跑定时任务还有三个容易被忽略的边界条件。第一所有 HTTP 请求都要加超时前面代码里统一写的timeout10。公共接口偶尔挂起没有超时的话脚本会被一次慢请求卡住cron 认为任务还在跑第二天日志里什么都没有。第二日志文件权限。cron 任务以当前用户身份运行脚本如果放在 root 目录里日志写不进去任务静默失败。检查方式手动执行一次脚本确认weather.log里真的有内容文件所有者是你自己的用户。第三天气 API 的日限额。和风开发版写的是每日 1000 次每天跑一次用 1 次理论上用不完但要小心 Key 泄漏——如果脚本被传到了公开仓库或日志里无意打印了 Key被爬虫扫到后额度可能几分钟耗光。怀疑泄漏时到和风后台重置 Key 即可。到这一步整套系统已经从手动进化成每天自动跑cron 负责叫醒脚本负责取数发送日志负责留痕。剩下的问题都在「异常情况怎么处理」上这也是下一章的主题。5. 天气推送避坑手册token 过期、字段不匹配和时区玄学这一章把我在三台不同机器上复现这个项目时踩过的坑整理成五条每条都是现象、原因、解决三段方便对照排查。5.1 40001第二天 token 就过期现象第一天手动跑通第二天 cron 日志里出现errcode: 40001access_token 明明没改过其他配置也都原样。原因access_token 有效期只有 7200 秒两小时就过期。更隐蔽的是如果脚本里自己缓存了 token 文件但没有判断过期时间或者从网上教程复制了「手动获取后硬编码」的写法第二天必然踩中。微信在同一段时间内多次调用 token 接口还会让旧 token 提前失效。解决脚本每次执行都实时获取新 token不做缓存。有人担心频率限制把 token 存文件里省请求最后反而在 token 过期判断上反复出 bug。实时获取是一次普通 HTTP GET对每天一次的任务毫无压力是最省心的写法。如果实在要缓存就同时存下expires_in和获取时间过期前 5 分钟再刷新但复杂度完全不值得。5.2 47003模板参数不匹配现象推送请求返回errcode: 47003提示 argument invalid代码语法、接口地址都检查过就是发不出去。原因data里的 key 和测试号后台模板的字段名不一致。最常见的几个场景模板里写的是{{weather.DATA}}脚本里写成weather_info字段名大小写不同模板保存后又改了字段脚本没有同步。微信端按模板逐字段解析少一个字段、多一个字段都会报 47003。解决把测试号后台的模板原文复制到本地文件写脚本时照着抄字段名不要凭记忆打。比对时注意大小写敏感city和City是两个字段。改完模板字段后强制手动执行一次脚本确认errcode: 0再交给 cron。下一章会讲一个用错误值自测字段映射的技巧能提前暴露这类问题。5.3 openid 填错要么发给自己要么报 40003现象消息没有发到女朋友微信而是发给了自己或者接口返回errcode: 40003invalid openid。原因openid 是用户在特定公众号下的身份标识不是微信号也不是接口返回的昵称。测试号后台「体验者」列表显示的是微信号有人直接拿微信号填进touser微信当然不认。另一个隐蔽场景同一个人关注了测试号和正式号两边的 openid 是两串完全不同的字符从别处复制错了就会发给自己或报错。解决openid 一律通过 2.3 节的user/get接口拉取不要从后台页面复制。拉取后和脚本里的OPENID逐字符比对openid 通常是 28 位左右的字符串以字母和数字混合。确认后手动发一条收到消息才算数。5.4 cron 不执行时区和路径排查现象脚本扔进 cron 后完全没有动静日志文件空着或者推送时间比设定整整晚了 8 小时比如设定 7 点实际 15 点收到。原因时间差 8 小时是时区问题服务器系统时区是 UTCcron 按系统时区解释时间字段。完全不执行则看三点cron 行里用了相对路径、python3 没写绝对路径、日志文件所在目录没有写权限。cron 自己不会告诉你任务失败了所有错误都只在重定向的日志里。解决按 4.1 节先timedatectl set-timezone Asia/Shanghai统一时区再检查 cron 行是否用了绝对路径。验证用「改到下一分钟」的办法等一分钟看日志。如果还没输出去/var/log/syslog里搜CRON关键字系统会记录每次执行命令和退出状态这是定位 cron 问题的最后一招。5.5 天气接口限流非 200 的重试策略现象日志里出现天气接口返回异常手动执行又恢复正常频率不高但确实发生过。原因免费天气接口的限流策略或者 Key 被泄漏后被人刷接口。我用和风跑了一年只遇到过一次连续几分钟非 200 的情况如果频繁出现先到 GitHub 或 Gitee 搜索自己的 Key 字符串确认没被公开被扫到了就重置 Key。解决脚本里保留 code 校验和异常抛出让问题能出现在日志里同时给天气请求加timeout10和重试失败一次后等 2 秒重试两次大多数临时限流都能扛过去。如果重试仍然失败宁可当天不发也不要编造天气数据——女朋友收不到消息最多问一句收到错误天气才是真翻车。6. 进阶玩法多城市、纪念日提醒和推送验证三板斧基础版能跑通后改造空间主要在消息内容和可靠性上。三个能力叠加成本很低效果却很明显。多城市支持解决异地恋场景把配置改成城市列表循环发送每个城市一个 LocationID 和 openid。核心改动是把单发逻辑包进循环CITIES [ {name: 北京, location: 101010100, openid: oXXXX-1}, {name: 上海, location: 101020100, openid: oXXXX-2}, ] for city in CITIES: w get_weather_today(city[location], QWEATHER_KEY) send_template_message(get_access_token(), city, w) time.sleep(1)纪念日提醒用日期差计算塞进模板的 note 字段from datetime import date start date(2023, 5, 20) days (date.today() - start).days note f今天是我们在一起的第 {days} 天这里再次体现时区统一的重要性date.today()用的是系统时区跨 8 小时边界时日期就差一天纪念日就算错。最后是验证三板斧我每次改完脚本都强制走一遍第一步手动执行确认日志里errcode: 0第二步故意把 data 里某个字段改成错误值重发一次比如 date 填9999-99-99确认返回 47003证明字段校验真的在工作第三步改回原值看消息是否按模板正确渲染。从那以后我每次改模板字段或者换城市 ID都会强制走一遍这三步验证流程几分钟的时间能堵住绝大多数低级错误。拆这类小自动化项目最大的体会是推送本身不难难的是把 token 过期、时区偏差、字段映射这些边界条件都摸透让系统每天无声地稳定运行。完整的脚本和配置清单我整理在下载资源里第一次搭照着改参数就行希望帮到你。本文还有配套的精品资源点击获取