项目名:Kebiao_Bot | 一台 792 MB 内存的 ARM 小主机,纯标准库,把课表变成每天准时出现的企业微信卡片。
不用 Docker、不用 pip、不用 Node、不装无头浏览器;常驻内存 9.2 MB,每天网络流量约 65 KB。
| 时间 | 卡片 | 内容 |
|---|---|---|
| 每天 07:00 | 今日课表 | 日期、剩余门数、天气、每节课的时间 / 教室 |
| 每天 21:00 | 明日课表 | 同上,门数为明日总门数 |
| 每 5 分钟 | 下节课提醒 | 上课前 15 分钟推一次,带 N分后 HH:MM 开始 |
| 每 10 分钟 | 健康检查 | 5 项巡检,只有状态翻转才推 |
上图由
tools/render_preview.py用 Pillow 近似渲染,方便改文案后立刻检查折行和留白;真机效果以企业微信客户端为准。
{ICS_URL} ← 上游每天生成的 iCalendar(云端 CI 定时产出)
│ 每小时/每天下载一次
▼
/opt/kebiao/daily.py 下载 + 解析 VEVENT → 按日期筛选
│
├──► card.py 今日 / 明日卡片
├──► next.py 课前 15 分钟提醒
└──► weather.py 天气行(城市码或经纬度)
│
▼
/opt/notify/notify.py → 企业微信群机器人(+ WxPusher 兜底)
关键设计:本机只做「消费端」。 抓课表需要账号密码,这部分交给云端 CI;小主机只下载公开的 ics 文件,因此机器上不存任何凭据,也解释了它为什么能安心跑在家庭网络里。
ics 里每个事件的字段:
BEGIN:VEVENT
DTSTAMP:20260101T000000 ← 生成时间(UTC),用来展示 Update:M.D
DTSTART:20260101T082500 ← 本地时间,无 TZID
DTEND:20260101T100000
SUMMARY:课程名
LOCATION:教室
DESCRIPTION:教师: xxx\n周次: 第1周\n节次: 第1-2节
END:VEVENT
Kebiao_Bot/
├── .gitattributes 强制 LF(bash / systemd unit 吃不了 CRLF)
├── .gitignore
├── kebiao/
│ ├── settings.py 配置聚合(环境变量 / /etc/*.env / 默认值)
│ ├── daily.py ics 下载 + 解析(被 card/next 复用)
│ ├── card.py 三种卡片生成 + 发送 + ASCII 预览
│ ├── next.py 课前提醒的挑选与去重
│ └── weather.py 天气行(qweather / open-meteo / sojson)
├── notify/
│ ├── notify.py 多通道发送(企业微信 + WxPusher)
│ ├── notify.sh 给 shell 用的包装
│ └── healthcheck.sh 5 项健康检查
├── deploy/
│ ├── *.service *.timer systemd 单元(4 个定时任务)
│ ├── install.sh 一键部署(幂等)
│ ├── uninstall.sh 卸载(保留密钥文件)
│ ├── privacy-check.sh 发布前自检
│ ├── kebiao.env.example 非密钥配置模板
│ └── notify.env.example 密钥模板
├── samples/sample.ics 虚构课表(自检用)
├── tests/selftest.py 22 项不联网自检
├── tools/render_preview.py 画卡片预览图
└── docs/card-preview.png
生产布局:
/opt/kebiao/ settings.py daily.py card.py next.py weather.py
/opt/notify/ notify.py notify.sh healthcheck.sh
/etc/kebiao.env 非密钥配置(600)
/etc/notify.env 密钥(600)
/run/kebiao-cache.ics 课表缓存(tmpfs)
/run/kebiao-weather.json 天气缓存(tmpfs)
/run/kebiao-next.state 提醒去重(tmpfs)
/run/kebiao-card.state 卡片去重(tmpfs)
/run/notify-health.state 健康检查状态(tmpfs)
# 1) 传到小主机(假设已配好 ssh)
scp -r Kebiao_Bot root@HOST:/root/
# 2) 部署(幂等,可反复跑)
ssh root@HOST 'bash /root/Kebiao_Bot/deploy/install.sh'
# 3) 填两个 env(脚本首次运行时会生成模板)
ssh root@HOST 'vi /etc/kebiao.env' # KEBIAO_ICS_URL / KEBIAO_CARD_LINK
ssh root@HOST 'vi /etc/notify.env' # WECOM_KEY
# 4) 干跑,确认排版后再等定时器
ssh root@HOST 'python3 /opt/kebiao/card.py --dry --tomorrow'
ssh root@HOST 'systemctl list-timers "kebiao-*" "notify-*" --no-pager'手动触发一次真推送:python3 /opt/kebiao/card.py --force。
| 键 | 位置 | 说明 |
|---|---|---|
KEBIAO_ICS_URL |
kebiao.env | 上游 ics 地址,占位符 {ICS_URL} |
KEBIAO_CARD_LINK |
kebiao.env | 整卡可点的落地页,必填,缺失会报 errcode 42045 |
KEBIAO_CITY_CODE |
kebiao.env | 天气城市码,占位符 {CITY_CODE} |
KEBIAO_LAT / KEBIAO_LON |
kebiao.env | open-meteo 用的经纬度(免密钥) |
KEBIAO_WEATHER_PROVIDER |
kebiao.env | qweather,open-meteo,sojson 的顺序,留空自动判断 |
QWEATHER_HOST / QWEATHER_KEY |
kebiao.env | 和风天气专属 API Host,占位符 {API_HOST} |
KEBIAO_NEXT_MINUTES |
kebiao.env | 提前提醒分钟数,默认 15 |
KEBIAO_WRAP_WIDTH |
kebiao.env | value 列折行宽度,默认 14(客户端约 13–14 字可见) |
HEALTH_URL 等 |
kebiao.env | 健康检查:HEALTH_DISK_MAX / HEALTH_MEM_MIN / HEALTH_REPEAT_AFTER |
CHANNEL |
notify.env | wecom、wecom,wxpusher、stdout |
WECOM_KEY |
notify.env | 群机器人 Webhook 的 key,占位符 {WECOM_KEY} |
WXP_SPT |
notify.env | WxPusher appToken,占位符 {WXP_SPT} |
KEBIAO_TIMER |
systemd | 1 表示由 timer 触发,启用时间窗守卫 |
全部使用 template_card 的 text_notice 子类型。
| 字段 | 今日课表 | 明日课表 | 下节课提醒 |
|---|---|---|---|
source.desc |
NailongBot Update:{M.D} |
同左 | NailongBot |
main_title.title |
今日课表 | 明日课表 | 下节课提醒 |
main_title.desc |
11月5日 周四 |
同左 | 15分后 08:25 开始 |
emphasis_content |
剩余门数 / 门剩余 | 总门数 / 门课 | 不发送 |
sub_title_text |
天气行 | 天气行 | 不发送 |
horizontal_content_list |
每门课一条 | 每门课一条 | 只有一条 |
card_action |
落地页(必填) | 同左 | 同左 |
Update: 的日期取自 ics 里最大的 DTSTAMP,按东八区换算,只到日。
卡片 value 列宽度有限,超过约 13–14 个汉字就会被客户端裁切;课名和教室必须完整显示,不缩写、不截断,所以由服务端主动折行:
- 优先断在
—— - 其次断在
、():·,;等标点(左括号留在下一行,避免孤儿行) - 最后才硬切
def wrap_cn(text, width=18):
if len(text) <= width:
return text
pos = text.find(u'——')
if 2 < pos + 2 <= width:
return text[:pos + 2] + '\n' + wrap_cn(text[pos + 2:], width)
# 否则在标点处断开,最差情况硬切wrap_cn 的默认宽度 18 是上限;卡片实际渲染用 KEBIAO_WRAP_WIDTH(默认 14),因为客户端 value 列比 18 窄。想验证效果直接 --dry,会打印带框线的 ASCII 预览。
| 单元 | 触发 | 说明 |
|---|---|---|
kebiao-daily.timer |
OnCalendar=*-*-* 07:00:00,Persistent=true |
今日课表 |
kebiao-tomorrow.timer |
OnCalendar=*-*-* 21:00:00,Persistent=true |
明日课表 |
kebiao-next.timer |
OnBootSec=3min + OnUnitActiveSec=5min |
课前提醒 |
notify-health.timer |
OnBootSec=2min + OnUnitActiveSec=10min |
健康检查 |
约束:常驻服务绑 0.0.0.0、用 systemd 管理、systemctl start 一律加 --no-block、状态文件放 /run(tmpfs)减少 eMMC 写入。
| 坑 | 现象 | 解法 |
|---|---|---|
| Python 3.5 无 f-string | SyntaxError: invalid syntax |
全部改 % 或 .format() |
date.fromisoformat |
AttributeError |
改 datetime.strptime(x, '%Y-%m-%d').date() |
| 天气接口 HTTPS 握手失败 | SSLV3_ALERT_HANDSHAKE_FAILURE |
改用 http://,并缓存到 /run |
| 和风天气 403 | Invalid Host |
新版控制台要专属 API Host,从项目页复制 |
卡片缺 card_action |
errcode 42045 |
必填,哪怕不可见 |
卡片带 jump_list |
底部多出「查看完整课表」入口 | 不需要就删掉整个字段 |
| 教室名被裁切 | 只显示一半 | 按 14–18 字主动折行,标点优先断 |
Persistent=true + 无 RTC |
断电重启后凌晨补推,日期算成 2016 年 | 年份守卫 + 时间窗守卫 |
| PowerShell 改 UTF-8 脚本 | 中文变乱码 | 用文件写入工具,别用 Set-Content 覆盖 |
内核 3.10 没有 MemAvailable |
健康检查读不到可用内存 | 回落 MemFree + Buffers + Cached |
两道守卫:
# 守卫一:时钟未校准则等待(本机无 RTC 电池)
while dt.date.today().year < 2025 and waited < 90:
time.sleep(10)
# 守卫二:由 systemd 触发时只在时间窗内推送
if os.environ.get('KEBIAO_TIMER'):
ok = (19 <= hour < 24) if tomorrow else (5 <= hour < 10)卡片发送还有第三道守卫:/run/kebiao-card.state 记录「哪天推过哪张卡」,Persistent=true 补跑时直接跳过,--force 可重推。
本仓库只有占位符:{ICS_URL}、{CARD_LINK}、{CITY_CODE}、{API_HOST}、{WECOM_KEY}、{WXP_SPT}、{JW_HOST}。示例课表内容全部虚构,密钥只存在于本机 /etc/notify.env(600)。
发布前跑一次自检:
bash deploy/privacy-check.sh
git status # 确认没有 secret.txt / 含真实数据的 *.json脚本把敏感关键词拆开拼接,避免检查脚本自己命中自己;命中任何一条就返回非零。
另外三条硬规矩:密钥不进 git、不进日志(notify.py 的错误信息里只有 errcode,没有 URL)、不进截图。
| 项 | 数值 |
|---|---|
| 常驻内存 | 9.2 MB(Python 单进程,跑完即退) |
| 峰值(渲染时) | ~30 MB |
| 磁盘 | ~30 KB 脚本 |
| eMMC 写入 | 仅在状态变化时,几十字节级(状态都在 /run) |
| 每日网络 | 约 60 KB(ics)+ 5 KB(天气) |
python3 tests/selftest.py # 22 项,不联网、不发送
python3 kebiao/card.py --dry --tomorrow --now 2026-11-04T21:00 --no-weather
python3 tools/render_preview.py # 重新生成 docs/card-preview.png兼容性:代码按 Python 3.5 写(无 f-string、无变量注解、无 date.fromisoformat),开发机上可以跑更高版本。改完代码建议这样校验语法:
python3 -c "import ast; ast.parse(open('kebiao/card.py').read(), feature_version=(3, 5))"MIT,见 LICENSE。
