利用 launchd 实现 Hexo 博客定时自动推送

利用 launchd 实现 Hexo 博客定时自动推送

零、 选择工具:Crontab vs Launchd

在 macOS 上实现自动化推送,通常有两种主流选择:

  • crontab

  • Launchd

1. Crontab (传统的 Unix 定时任务)
操作指令:

  1. 在终端输入 crontab -e 进入编辑界面。
  2. 添加一行配置(例如每天 22:30 执行):
    30 22 * * * /bin/bash /Users/focus/Library/LaunchAgents/auto_push.sh >> /tmp/hexo_cron.log 2>&1
  3. 保存并退出即可。

特点: 语法极其简单,配置迅速。但它的致命缺点是不支持“补跑”。如果 22:30 你的 Mac 盖上了盖子处于休眠状态,crontab 任务就会直接跳过,直到第二天同一时间。

2. Launchd (Apple 官方)
特点: 虽然 .plist 的 XML 格式比 crontab 复杂,但其拥有 StartCalendarInterval 机制。如果设定时间点电脑在休眠,launchd 会在系统唤醒后立即补跑任务。对于笔记备份和博客推送这种“必须成功”的任务,launchd 是更可靠的选择。

一、确认环境

在编写脚本前,需在终端执行以下诊断,确保命令在“纯净环境”下依然有效。

1. 确认命令的物理路径

launchd 不识别 hexogit 这种简写,需提供绝对路径。

  • 输入指令which hexo
  • 预期结果:记录返回的路径(如 /opt/homebrew/bin/hexo)。
  • 在脚本中替换所有 hexo 字样,防止 command not found

2. 确认环境变量 $PATH

脚本需要知道去哪里寻找执行引擎(如 Node.js)。

  • 输入指令echo $PATH
  • 预期结果:一串由冒号分隔的路径(如 /opt/homebrew/bin:/usr/local/bin...)。
  • 将这串路径复制,写在脚本开头的 export PATH="..." 后面。

3. 确认 SSH 密钥授权状态

后台运行无法弹出密码框,必须确认密钥已托管给系统。

  • 输入指令ssh-add -l
  • 预期结果:看到密钥指纹。如果显示 The agent has no identities,则需要在脚本中手动加载。
  • 手动执行 hexo d。如果无需输入密码即可推送,则配置正确。

二、自动化 Shell 脚本

我们需要创建一个封装脚本,手动重构环境变量并定位物理路径。

脚本位置: ~/Library/LaunchAgents/auto_push.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/bin/bash

# 1. 显式声明 PATH,包含 Homebrew 和 Conda 路径
export PATH="/Users/focus/miniconda3/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"

# 2. 加载 SSH 密钥(关键:解决后台运行无身份认证问题)
ssh-add --apple-use-keychain ~/.ssh/id_rsa 2>/dev/null

# 3. 业务逻辑
BLOG_DIR="/Users/focus/WhereYourBlogIs"
DINGTALK_URL="https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN"
cd "$BLOG_DIR" || exit #前者执行不了就退出

# 检索过去 24 小时内更新的文章
CHANGES=$(/usr/bin/find -L "/Users/focus/Desktop/blog/my_blog/source/_posts/" \
-type f -mmin -1560 -name "*.md" | /usr/bin/sed "s|.*/_posts/|- |")
[ -z "$CHANGES" ] && CONTENT="中哥,今日无新文章推送。" || CONTENT="中哥,今日新推送文章:\n$CHANGES"

# 4. 执行 Hexo 流程
/opt/homebrew/bin/hexo cl && /opt/homebrew/bin/hexo g && /opt/homebrew/bin/hexo d

# 5. 钉钉机器人回传结果
if [ $? -eq 0 ]; then
curl -s "$DINGTALK_URL" -H 'Content-Type: application/json' \
-d "{\"msgtype\": \"text\", \"text\": {\"content\": \"$CONTENT\"}}"
fi

Shell 脚本逻辑框架极速学习

模块对应 Python 概念作用细节
#! /bin/bash\告诉系统用哪个“翻译官”来读这个文件。必须放在第一行。
export PATHos.environ['PATH']重构环境。让脚本能找到 node, hexo 等工具。后台运行不读 .zshrc,必须手动声明。
ssh-addAuthentication免密授权。从系统钥匙串读取 Git 推送所需的私钥。后台运行无法手动输密码,需靠此指令。
VariablesVariable (变量)存储路径、URL、检索结果。注意:等号两边不能有空格
$(command)subprocess.check_output命令替换。执行一个命令并把输出结果存入变量。CHANGES=$(find...)
$ (逻辑判断)if not CHANGES:短路逻辑。根据变量是否为空生成不同的通知文案。[ -z "$VAR" ] 是检查字符串是否为空。
$? (状态码)try...exceptreturn code捕获上一步状态0 代表成功,非 0 代表报错。if [ $? -eq 0 ] 决定是否发钉钉消息。
curlrequests.post()网络请求。向钉钉 API 发送 JSON 数据。使用 -d 参数传递 JSON 字符串。

三元运算符

1
[ -z "$CHANGES" ] && CONTENT="A" || CONTENT="B"
  1. [ -z "$CHANGES" ]:这是一个判断题。-z 表示 “Zero”,意思是“检查字符串长度是否为 0”。
    • 如果今天没写文章,$CHANGES 是空的,判断为 真(True)
    • 如果今天写了文章,判断为 假(False)
  2. && (逻辑与):只有当前面的判断为 时,才执行。
  3. || (逻辑或):只有当前面的判断为 时,才执行。
  • 路径 A (没写文章)
    判断为真 → 触发 && 后面的赋值 → 跳过 ||
    结果:CONTENT 变成了“今日无新文章”。
  • 路径 B (写了文章)
    判断为假 → 跳过 && → 触发 || 后面的赋值。
    结果:CONTENT 变成了“今日新推送文章:…”。

$()先运行内部,然后命令替换

三、配置调度:launchd Plist 文件

~/Library/LaunchAgents/ 下创建 com.user.pushblog.plist,定义触发时间。

1. plist 基础结构

plist 是 XML 格式的键值对列表。所有配置必须包裹在 <dict> 标签内。

XML

1
2
3
4
5
6
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>

2. 参数表

键名 (Key)数据类型说明示例
Labelstring唯一身份标识。任务的“身份证号”,不可重复。com.focus.blog
ProgramArgumentsarray执行指令序列。第一个值是解释器,第二个是脚本路径。/bin/bash, /path/to/sh
StartCalendarIntervaldict定时触发器。设定具体的执行时间点。Hour: 22, Minute: 30
StandardOutPathstring标准输出日志。保存脚本运行时的正常提示。/tmp/blog.log
StandardErrorPathstring标准错误日志。保存脚本运行失败的报错信息。/tmp/blog.err

3. 格式例子

  • 定点触发(每天 22:30):

    1
    2
    3
    4
    5
    <key>StartCalendarInterval</key>
    <dict>
    <key>Hour</key><integer>22</integer>
    <key>Minute</key><integer>30</integer>
    </dict>
  • 周期触发(每隔 1 小时/3600 秒):

    1
    2
    <key>StartInterval</key>
    <integer>3600</integer>

4. 增强功能

  • RunAtLoad: bool 类型。设为 <true/> 则在任务加载或开机时立即执行一次。
  • WorkingDirectory: string 类型。指定脚本运行时的根目录,免去脚本内 cd 的麻烦。
  • KeepAlive: bool 类型。如果程序崩溃,系统会自动重启它(适用于常驻服务)。

5. 编写

  1. 语法校验:编写完成后,使用终端指令检查 XML 格式是否合法:

    plutil -lint ~/Library/LaunchAgents/your_filename.plist

  2. 绝对路径原则plist 内部禁止使用 ~ 或相对路径,必须使用全路径(如 /Users/focus/...)。

  3. 生效逻辑:修改 plist 内容后,必须执行 unloadload,系统才会读取新配置。


6. 操作速查

  • 加载launchctl load [文件路径]
  • 卸载launchctl unload [文件路径]
  • 启动launchctl start [Label]
  • 停止launchctl stop [Label]

四、常用操作指令集

  • 赋权脚本:
    chmod +x ~/Library/LaunchAgents/auto_push.sh

  • 加载任务:
    launchctl load ~/Library/LaunchAgents/com.user.pushblog.plist

  • 手动强制触发测试:(停止stop)
    launchctl start com.user.pushblog

  • 卸载任务(修改配置后必须执行):
    launchctl unload ~/Library/LaunchAgents/com.user.pushblog.plist

  • 查看运行日志:
    tail -f /tmp/hexo_push.log

  • 完整查看日志:

    cat /tmp/hexo_push.log

Launchd 运维速查笔记(发疯记录)

1. 操作顺序(先下后上)

原则:修改 plist 文件或脚本内容后,必须遵循“先卸载、再加载”的闭环,否则系统缓存会导致配置不生效或报错。

  1. 卸载 (Unload)launchctl unload ~/Library/LaunchAgents/com.user.pushblog.plist
  2. 修改 (Modify):编辑 .plist.sh 文件。
  3. 加载 (Load)launchctl load ~/Library/LaunchAgents/com.user.pushblog.plist
  4. 测试 (Test)launchctl start com.user.pushblog(手动触发一次验证逻辑)。

2. 报错处理:Load failed: 5 (I/O Error)

  • 现象:执行 load 时提示 Input/output error
  • 病因:任务已在内存中加载,或在未卸载时修改了 Label 导致冲突。
  • 药方
    • 先执行 unload 强行清理。
    • 检查 XML 语法:plutil -lint [路径]
    • 若仍不行,多unload几次

3. 静默卸载 (2>/dev/null)

  • 用法launchctl unload [路径] 2>/dev/null
  • 作用:将错误输出(Standard Error)重定向到不输出。
  • 场景:当你编写自动化脚本(如 deploy.sh)时,不确定任务是否已加载。加上这一句可以确保无论任务是否存在,都不会弹出干扰报错,直接执行后续的 load

4. 状态检查:launchctl list | grep pushblog

观察任务“活得好不好”。输出通常包含三列:

PID (进程号)Last Status (状态码)Label (任务名)
123450com.user.pushblog
-0com.user.pushblog
-78com.user.pushblog
  • PID 为数字:说明脚本正在后台运行。
  • PID 为 -:说明任务已加载,正在等待定时触发。
  • Status 为 0:上次运行完美成功
  • Status 非 0:上次运行失败(例如 78 通常代表路径错误或权限不足)。

5. 核心Bug:macOS 隐私沙盒 (FDA)

这是本次任务“手动行,自动不行”的头号杀手

  • 现象:脚本在终端运行完美,但通过 launchd (plist) 运行时,find 无法读取 Desktop 目录下的文章。
  • 原理:后台进程默认被禁止访问用户敏感目录。即便脚本路径正确,系统也会静默拦截 find 的深入探测。
  • 对策:在 系统设置 -> 隐私与安全性 -> 完全磁盘访问权限 中,手动添加 /bin/bash(或你的脚本解释器)并开启开关。

6. 工具差异:BSD find 的“洁癖”

macOS 自带的是 BSD 版 find,它比 Linux 的 GNU 版更固执。

  • 坑点:不支持浮点数(如 -mtime -1.1 会报错 bad unit)。
  • 对策:改用分钟单位 -mmin。例如用 -mmin -1560 代替 -mtime -1.1,实现更精准、兼容性更好的时间窗口控制。

7. 环境隔离:极简的 Shell 环境

launchd 运行脚本时不会加载你的个人配置文件(.zshrc.bash_profile)。

  • 坑点:缺少 PATH 可能导致找不到 hexo;缺少 LANG 可能导致处理中文路径时 sed 正则失效。
  • 对策:在脚本开头硬编码 export PATHexport LANG=en_US.UTF-8