简介:围绕「微信公众号每日定时推送天气与纪念日提醒」的自动化小项目,这份教程资源面向有少量编程基础、希望亲手为伴侣制作关怀工具的开发入门者与爱好者。包内提供 GitHub Actions 每日自动运行的线上版本,配合消息模板可直接替换内容,涵盖最新完整版、简化版、星座版与 PHP 版等多种形态,选择空间较大。压缩包共 5 个文件、约 5KB,以 py 主程序、yml 工作流配置、txt 参数配置与 md 说明文档为主,分别承担业务逻辑、定时任务调度、密钥与推送参数填写、部署指引等职责,结构精简,便于逐行阅读与二次修改。目前已有 7698 人学习下载。读者可从中获得一套可跑通的推送脚本与消息模板,理解定时任务、API 调用与参数配置之间的关系,并借助配置文件快速切换天气源与推送内容,减少从零摸索的成本。
1. 天气推送这件事,本质是四个小零件串起来
去年冬天某个周一早上七点,她的手机弹出一条消息:抬头是她所在城市,正文写着“多云转小雨,18~24℃,东南风 2 级,出门带伞”,末尾还多了一句“你上周说这两天要体检,别喝豆浆”。她截图发过来问是不是我搞的,我说是。这就是一个微信公众号天气推送小工具能做到的全部——定时器准点跑、天气接口拉数据、模板消息塞进微信对话框,三个动作,代码不到三百行。它不需要买服务器,白嫖额度就能长期活着。适合会一点 Python、想让公众号干点私活的人,也适合拿它练“定时任务 + 开放接口 + 消息下发”这条完整链路的人。下面按我拆解的顺序原样走一遍:先看它由哪几块拼成,再一步步跑通,然后抓出最容易翻车的地方。
2. 架构与技术选型:一条消息背后的四段链路
2.1 从触发到送达,拆成四个可以独立替换的环节
很多人第一次做这类推送,喜欢写一个大函数从头撸到尾,结果天气接口一换、消息格式一改,整段代码就得重写。我习惯按“谁触发、谁供数、谁渲染、谁发送”切成四段,每段留一个明确的输入输出契约,后面想换成邮件、钉钉或者短信,只动最后一段就行。
| 环节 | 干的事 | 常见方案 | 这里的选型 |
|---|---|---|---|
| 触发 | 到点启动程序 | 服务器 cron、云函数定时触发器、流水线定时任务 | 流水线定时任务,免运维 |
| 取数 | 拉当天/未来三天天气 | 各家天气开放接口 | 一个走 HTTP JSON 的天气服务 |
| 渲染 | 把字段拼成消息体 | 模板消息、订阅通知、客服消息 | 公众号模板消息 |
| 送达 | 调接口推给指定用户 | 公众号 / 小程序订阅 | 公众号,靠 openid 定位收件人 |
这四个环节里,只有“送达”是强绑定公众号的,其余三个都能复用。我一直觉得这种切法是这类小项目最值钱的地方——它让你后面加功能时不用推倒重来。
2.2 账号类型直接决定你能发哪种消息
这是新手最容易忽略、也最容易白干一天的地方。公众号的消息能力跟账号类型强相关:订阅号每天能群发一次,但模板消息这类“服务通知”能力受限;服务号能力完整,可需要主体认证;而个人开发者最省事的路子,是用测试号先把链路跑通。
测试号的好处是模板消息几乎随便加、模板内容自己定,不用等审核,扫码就能拿到一个可用的 openid。血泪经验是:先用测试号把“取数 → 渲染 → 发送”全跑通,再考虑要不要迁到正式号。因为正式号的模板字段是固定的,你得先知道自己需要哪几个字段,再去挑模板,顺序反了就得反复改结构。
常见做法是维护一份配置表,把 appid、secret、模板 ID、收件人 openid 全放环境变量里,代码里只读不写死。这样测试号和正式号之间切换,改的只是运行环境,不是源码。
2.3 目录结构:小项目也要有边界
项目不大,但我不建议所有代码塞一个文件。按职责切一层,后面加人多城市、加纪念日倒计时才不至于乱。
weather-push/ ├── app/ │ ├── config.py # 环境变量集中读取,唯一配置入口 │ ├── weather.py # 取数 + 字段归一化 │ ├── wechat.py # token 管理 + 消息发送 │ ├── template.py # 消息体组装与文案规则 │ └── storage.py # token / 发送记录的持久化 ├── scripts/ │ └── run_once.py # 一次完整执行的入口,便于手动干跑 ├── .github/workflows/ │ └── daily.yml # 定时触发配置 ├── requirements.txt └── README.mdrun_once.py这个文件值得单独说。它把“一次推送”封装成可手动执行的命令,本地python -m scripts.run_once就能跑,不用等到第二天早上七点看结果。调试期我几乎全靠它,流水线只是把这个命令换个地方执行而已。
3. 跑通全流程:从环境变量到手机弹出第一条消息
3.1 把凭证收进配置层
先把所有会变的东西抽到环境变量。这一步看起来啰嗦,但它决定了你后面能不能安全地把代码传到公开仓库——密钥写死在源码里,一旦推上去就得全部重置。
# app/config.py import os from dataclasses import dataclass @dataclass(frozen=True) class Settings: app_id: str = os.getenv("WX_APP_ID", "") app_secret: str = os.getenv("WX_APP_SECRET", "") template_id: str = os.getenv("WX_TEMPLATE_ID", "") openid: str = os.getenv("WX_OPENID", "") city: str = os.getenv("WEATHER_CITY", "101010100") # 城市编码,按服务商规则填 weather_key: str = os.getenv("WEATHER_API_KEY", "") weather_host: str = os.getenv("WEATHER_API_HOST", "") token_cache: str = os.getenv("TOKEN_CACHE_FILE", ".cache/token.json") settings = Settings()用dataclass(frozen=True)是为了让它一旦实例化就不可改,避免某个模块顺手把settings.openid覆盖掉造成“发给了错误的人”这种玄学问题。城市用编码而不是中文名,是因为多数天气接口对中文名的匹配是模糊的,同名地名会被解析到别的区域。
3.2 取数与字段归一化
接口返回的原始 JSON 通常字段很多、命名风格各异,直接拿去拼消息会让模板层跟服务商强绑定。加一层normalize,把需要的东西挑出来重命名,后面换服务商只改这一个函数。
# app/weather.py import requests from app.config import settings def fetch_weather(city: str) -> dict: url = f"https://{settings.weather_host}/v7/weather/3d" params = {"location": city, "key": settings.weather_key} resp = requests.get(url, params=params, timeout=8) resp.raise_for_status() data = resp.json() if str(data.get("code")) != "200": raise RuntimeError(f"天气接口返回异常: {data.get('code')}") return normalize(data) def normalize(raw: dict) -> dict: today = raw["daily"][0] # 第 0 项是今天 return { "city": raw.get("city") or settings.city, "date": today["fxDate"], "text_day": today["textDay"], "text_night": today["textNight"], "temp_min": int(today["tempMin"]), "temp_max": int(today["tempMax"]), "humidity": today["humidity"], "wind": f'{today["windDirDay"]}{today["windScaleDay"]}级', }timeout=8是必须的。定时任务跑在别人的机器上,网络抖动时没有超时的请求会一直挂着,直到整个任务被判超时失败,日志里还看不出到底卡在哪。raise_for_status()负责把 4xx/5xx 变成异常,业务层的code判断负责把“请求成功但业务失败”也拦下来——这两种失败在日志里必须能区分开,不然排查时全是黑匣子。
3.3 access_token 的取用与消息发送
公众号的接口凭证有有效期,每次发送前都重新取一遍,会在高频调用时被限流;完全不缓存,又容易在关键时刻拿到过期值。我的做法是落一份带过期时间的本地缓存。
# app/wechat.py import json, os, time, requests from app.config import settings API = "https://api.weixin.qq.com" def _load_cache() -> dict: if not os.path.exists(settings.token_cache): return {} with open(settings.token_cache, encoding="utf-8") as f: return json.load(f) def _save_cache(obj: dict) -> None: os.makedirs(os.path.dirname(settings.token_cache), exist_ok=True) with open(settings.token_cache, "w", encoding="utf-8") as f: json.dump(obj, f) def get_access_token(force: bool = False) -> str: cache = _load_cache() # 提前 5 分钟过期,避免边界时刻拿到已失效的 token if not force and cache.get("expires_at", 0) > time.time() + 300: return cache["access_token"] resp = requests.get(f"{API}/cgi-bin/token", params={ "grant_type": "client_credential", "appid": settings.app_id, "secret": settings.app_secret, }, timeout=8) data = resp.json() if "access_token" not in data: raise RuntimeError(f"取 token 失败: {data}") _save_cache({ "access_token": data["access_token"], "expires_at": time.time() + data["expires_in"], }) return data["access_token"]发送函数里带一次强制刷新重试,是因为 token 可能被别处(比如你手动调接口)顶掉,这时候缓存里的值虽然没过期但已经失效了。
# app/wechat.py 续 def send_template(payload: dict) -> dict: url = f"{API}/cgi-bin/message/template/send" token = get_access_token() data = requests.post(url, params={"access_token": token}, json=payload, timeout=8).json() if data.get("errcode") == 40001: # 凭证失效,刷新后重试一次 token = get_access_token(force=True) data = requests.post(url, params={"access_token": token}, json=payload, timeout=8).json() return data参数上,touser是收件人 openid,template_id是模板编号,data里的每个 key 必须和模板里定义的字段名完全一致,url可选,点了消息会跳转。
3.4 消息体组装:把数字翻译成人话
模板消息本身只是个填空题,真正决定体验的是文案规则。我把它单独放一层,方便按天气条件改语气。
# app/template.py def make_tip(w: dict) -> str: tips = [] if "雨" in w["text_day"] or "雨" in w["text_night"]: tips.append("出门带伞") if w["temp_max"] - w["temp_min"] >= 10: tips.append("早晚温差大,备件外套") if w["temp_max"] >= 32: tips.append("注意补水") if not tips: tips.append("今天适合出门走走") return ",".join(tips) def build_payload(w: dict) -> dict: from app.config import settings return { "touser": settings.openid, "template_id": settings.template_id, "data": { "date": {"value": w["date"]}, "city": {"value": w["city"]}, "weather": {"value": f'{w["text_day"]}转{w["text_night"]}'}, "temp": {"value": f'{w["temp_min"]}~{w["temp_max"]}℃'}, "wind": {"value": w["wind"]}, "tip": {"value": make_tip(w)}, }, }safe之外别自作聪明加富文本,模板消息的 value 会原样渲染,塞 HTML 只会显示成一堆尖括号。
3.5 挂上定时任务
最后一环是让它每天自动跑。用流水线的定时触发器最省事,不用维护机器。
# .github/workflows/daily.yml name: daily-weather-push on: schedule: - cron: "0 23 * * *" # UTC 23:00 = 北京时间次日 07:00 workflow_dispatch: # 保留手动触发,方便调试 jobs: push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - uses: actions/cache@v4 with: path: .cache key: token-cache restore-keys: token-cache - run: pip install -r requirements.txt - run: python -m scripts.run_once env: WX_APP_ID: ${{ secrets.WX_APP_ID }} WX_APP_SECRET: ${{ secrets.WX_APP_SECRET }} WX_TEMPLATE_ID: ${{ secrets.WX_TEMPLATE_ID }} WX_OPENID: ${{ secrets.WX_OPENID }} WEATHER_CITY: ${{ secrets.WEATHER_CITY }} WEATHER_API_KEY: ${{ secrets.WEATHER_API_KEY }} WEATHER_API_HOST: ${{ secrets.WEATHER_API_HOST }}cron用的是 UTC,这一点坑了我一次——按本地时间写 7 点,结果实际在下午三点推。缓存那一步是为了让 token 跨次复用,不然每天都要重新取一次。
4. 核心模块拆解:几个决定成败的细节
4.1 token 缓存放在哪,决定了它会不会重复推送
文件缓存最简单,但在流水线环境里有个前提:运行环境必须能把文件留下来。如果每次都是干净容器、又没有缓存步骤,那这份缓存等于没有,每天都重新取 token。这本身不算错,只是浪费调用;真正麻烦的是多个任务并发跑时,各自取一次 token,先取的会被后取的顶掉,于是出现“刚刚还好好的,突然 40001”。
我一般会把 token 的获取收敛成单入口,并在异常信息里带上errcode和errmsg。日志里只写“发送失败”是没用的,你得知道是凭证问题、参数问题还是用户没关注。
4.2 模板字段映射:名字对不上就是 47003
模板消息最常见的报错是 47003,意思是参数不匹配。原因几乎都是data里的 key 跟模板里定义的字段名差了哪怕一个字符。稳妥的做法是先在测试号里把模板字段定下来,把 key 抄进代码,再去做发送。
| 模板字段 | 含义 | 示例值 | 注意事项 |
|---|---|---|---|
| date | 日期 | 2025-03-18 | 别用带时分秒的格式 |
| city | 城市 | 杭州 | 太长会被截断 |
| weather | 天气 | 多云转小雨 | 昼夜拼接,控制长度 |
| temp | 温度区间 | 18~24℃ | 用~不用- |
| wind | 风力 | 东南风2级 | 不要写成数字 |
| tip | 提醒语 | 出门带伞 | 别超过一行 |
字段值长度其实有上限,超了会被静默截断,你看到的消息就少半句。我一般把 tip 控制在 20 字以内。
4.3 文案规则:别让模板变成复读机
同一个模板天天发,很容易被忽略。加一点条件判断,让它每天说的话不一样,效果差别很大。除了天气,还可以塞进“距离某个日子还有几天”“今天是周几”这类信息。这部分不建议搞太复杂,规则式判断就够,硬上生成模型反而容易输出不合时宜的内容。
4.4 发送记录:出问题时唯一的后悔药
哪怕不建数据库,也建议每天落一行记录:日期、城市、消息体、返回码。有一次我改了文案规则,结果连续三天都没收到消息,翻日志才发现是tip字段超长导致整条消息被拒,而我那几天根本没看返回码——因为脚本退出码是 0。
# app/storage.py import json, os, datetime def log_send(payload: dict, result: dict, path: str = "logs/send.log") -> None: os.makedirs(os.path.dirname(path), exist_ok=True) line = { "ts": datetime.datetime.now().isoformat(timespec="seconds"), "city": payload["data"]["city"]["value"], "errcode": result.get("errcode", 0), "errmsg": result.get("errmsg", "ok"), } with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(line, ensure_ascii=False) + "\n")关键是要在run_once.py里判断errcode,非 0 就sys.exit(1)。不然流水线显示绿色成功,你却在那边纳闷为什么手机没响。
5. 避坑与排查:几个真实翻过的车
5.1 现象:流水线显示成功,手机没收到
原因:发送接口返回了业务错误码,但脚本没检查,进程正常退出。公众号的接口 HTTP 状态码通常是 200,错误全藏在响应体的errcode里。
解决:发送后立刻判断errcode,非 0 打印完整响应并退出非零码。同时把返回体写进日志文件,方便事后翻。
5.2 现象:报 40001,刷新 token 也没用
原因:多半是 appid 和 secret 不匹配,或者 secret 被重置过。还有一种情况是同一套凭证被两处代码同时刷新,互相顶掉。
解决:先确认环境变量里填的 secret 是当前生效的那一份;再确认只有一处代码在取 token。如果是多任务并发,把取 token 和发送放在同一个任务里,别拆成两步。
5.3 现象:定时任务在错误的时间触发
原因:cron表达式按 UTC 计算,本地时间写进去会差 8 小时。另外流水线的定时触发本身有延迟,高峰期可能晚十几分钟。
解决:换算成 UTC 再写,并在注释里标明对应的本地时间。对时间精度要求高的场景(比如必须七点整),这套方案不合适,得换常驻机器加系统级定时。
5.4 现象:连续几天推送内容都一样
原因:天气接口的响应被运行环境缓存了,或者代码里的日期取的是容器时区。
解决:请求时带上时间戳参数或用Cache-Control: no-cache;日期显式用目标时区计算,别依赖datetime.now()的默认时区。
5.5 现象:迁移到正式号后全部报错
原因:测试号的模板字段可以自由定义,正式号的模板是平台给定的,字段名和数量都不一样。
解决:先在正式号里挑模板,把字段名抄下来,改build_payload里的 key,再跑一次手动触发验证。别直接改完就等第二天。
6. 二次开发:把它改成你自己的每日简报
跑到这一步,其实你已经有了一个“定时取数 + 消息下发”的骨架,天气只是最顺手的一个数据源。我后来在这个骨架上做了几件事,值得说一说。
第一件是多收件人。把openid从单个值改成列表,循环发送,每次之间加 200 毫秒间隔,避免触发频率限制。列表可以放配置文件里,也可以放一张简单的表。如果要给不同的人推不同城市,就把city也一起存进去,build_payload多接一个参数。
第二件是条件推送。不是每天都值得打扰别人,可以加一层判断:只有下雨、温差超过 10 度、或者空气质量差的时候才发。这需要把“是否推送”的逻辑从发送层里抽出来,单独写一个should_push(w)函数,返回布尔值加原因。这样做的好处是你可以先干跑一周,看看到底会触发几次,再决定阈值。
第三件是加被动回复。公众号除了主动推,还能在用户发消息时回复。加一个简单的关键词解析,“天气”回当天天气,“明天”回明天的,“倒计时”回距离某个日子还有几天。这块要注意的是,接收消息需要配置服务器地址并做签名校验,比主动推送麻烦不少。我的建议是先用主动推送把体验跑顺,再考虑被动回复。
第四件是换存储。文件日志适合单人使用,多人多城市之后就该上数据库了。最省事的是一张发送记录表加一张用户偏好表,字段不用多,够查就行。
改造完怎么验证?我习惯做两件事:一是干跑模式,加一个DRY_RUN=1的环境变量,走到发送那一步就打印消息体不真发;二是构造异常输入,把城市编码改成不存在的值、把 token 手动改坏,看程序是不是按预期报错而不是静默成功。这两步花不了十分钟,但能省掉很多第二天早上的困惑。
有个教训我记了很久:最早那版我把所有逻辑写在一个脚本里,token 缓存、文案规则、发送全糊在一起。后来想加个“下雨才提醒”,改了半小时发现改错了地方,把温度判断的条件也带崩了,连续两天推了错误的提醒。从那以后我每次给这类小项目加功能,都强制先把要改的那层单独跑一遍,确认输入输出对得上,再合进去。
希望帮到你。
本文还有配套的精品资源,点击获取