macOS の launchd で「巡回して差分だけ記録する常駐」と「止まったら起こし直す見張り」を並べるための雛形。 60秒ごと・10分ごとといった間隔で走らせ、前回との差分(増えた・消えた)だけを JSONL に追記する。多重起動よけ・状態ファイルの原子的な差し替え・無音検知による自動復旧・ログのローテートまでを一式にしてある。
自分の環境で70本の常駐を動かすうちに毎回書いていた型を、対象や個人の記録を含まない骨組みだけ書き直して切り出したもの。
examples/ の JSON を取得元にして3回走らせたところ。
$ python3 bin/watcher.py --config examples/config.json
2026-09-03T12:49:03+09:00 demo: 取得2件 新規2 消失0 (0.00s)
$ python3 bin/watcher.py --config examples/config.json # 取得元は変化なし
2026-09-03T12:49:03+09:00 demo: 取得2件 新規0 消失0 (0.01s)
$ python3 bin/watcher.py --config examples/config.json # 1件消えて1件増えた
2026-09-03T12:49:05+09:00 demo: 取得2件 新規1 消失1 (0.01s)
追記された demo.events.jsonl。消えた側は取得元にもう無いので、前回保存しておいた中身をそのまま証拠として残す。
{"ts":"2026-09-03T12:49:03+09:00","event":"added","fp":"87c1cf75313521e7","item":{"id":"1","title":"最初の項目","url":"https://example.com/1"}}
{"ts":"2026-09-03T12:49:03+09:00","event":"added","fp":"08ccf130516d506b","item":{"id":"2","title":"次の項目","url":"https://example.com/2"}}
{"ts":"2026-09-03T12:49:05+09:00","event":"added","fp":"e4889b52ceb1beb7","item":{"id":"3","title":"あとから増えた項目","url":"https://example.com/3"}}
{"ts":"2026-09-03T12:49:05+09:00","event":"removed","fp":"87c1cf75313521e7","item":{"id":"1","title":"最初の項目","url":"https://example.com/1"}}launchd ──StartInterval 60秒──▶ watcher.py ──▶ 取得(差し替え可能な1関数)
│
├─ 比べる列だけから指紋を作る(並び順や広告に反応させない)
├─ 前回の指紋一覧と突き合わせて added / removed を出す
├─ 差分があった時だけ events.jsonl に追記する
└─ 状態ファイルを一時ファイル経由で差し替える
launchd ──StartInterval 120秒─▶ keeper.sh ──▶ 状態ファイルの最終更新を見る
└─ 許容した無音を超えていれば launchctl kickstart -k
(前回の起こし直しから5分は再実行しない)
設計で決めていること。
- 常駐ループを自前で持たない。 1回の実行で必ず終わる。落ちた時の復旧を launchd に任せられる
- launchd の
KeepAliveだけに頼らない。 あれは終了したプロセスしか拾えず、生きたまま仕事が止まった状態を検知できない。だから「成果物の最終更新時刻」を心拍として別に見張る - 差分の判定に使う列を設定で明示させる。 取得元は毎回いらない値を混ぜてくるので、全体をハッシュすると毎回「全件が変わった」ことになる
| 言語 | Python 3.9+(標準ライブラリのみ・追加インストール不要)、zsh |
| 常駐 | macOS launchd(StartInterval / RunAtLoad / ProcessType Background) |
| 排他 | fcntl.flock による非ブロッキングのロック |
| 記録 | JSONL 追記(イベント)+ JSON(状態・一時ファイル経由で差し替え) |
| 復旧 | launchctl kickstart -k + 再実行の冷却時間 |
| 対応 | macOS 12 以降(launchctl bootstrap / bootout の記法を使用) |
git clone https://github.com/Emocute/launchd-kit.git
cd launchd-kit1. 取得部を書く。 bin/watcher.py の fetch() だけを差し替える。戻り値を list[dict] に揃えれば他は触らなくていい。雛形にはローカル JSON を読む実装だけ入れてある。
2. 設定を書く。 examples/config.json を複製する。
{
"name": "myjob",
"state_dir": "~/.local/state/launchd-kit",
"compare_keys": ["id", "title"],
"detect_removal": true,
"source": { "kind": "jsonfile", "path": "examples/source.json" }
}3. 手で1回走らせて確かめる。 launchd に載せるのはここが通ってから。
python3 bin/watcher.py --config examples/config.json4. 常駐として登録する。 最後の数字が巡回間隔(秒)。
bin/install.sh watch com.example.myjob examples/config.json 605. 見張りを付ける。 状態ファイルが300秒更新されなければ起こし直す。見張り自身は120秒ごとに走る。
bin/install.sh keeper com.example.myjob-keeper com.example.myjob \
~/.local/state/launchd-kit/myjob.state.json 300 1206. 様子を見る/外す。
bin/logs.sh list # 登録状況とログの大きさ
bin/logs.sh tail com.example.myjob
bin/logs.sh rotate 10 # 10MB を超えたログを切って gzip
bin/install.sh uninstall com.example.myjobログは ~/Library/Logs/launchd-kit/<ラベル>.log、状態とイベントは state_dir に出る。
- launchd から起動されるプロセスは対話シェルの
PATHを持たない。 雛形の plist で最小限のPATHを明示している。Homebrew 以外の場所にある実行ファイルを呼ぶ場合はここに足す - 画面のロック中やスリープ中は
StartIntervalの回が飛ぶ。 復帰後にまとめて1回走る。取りこぼしを許さない用途には向かない - 取得先にネットワーク越しでアクセスする場合は、相手の利用規約と間隔を必ず確認すること。 雛形の既定を60秒にしてあるのは自分の用途の値であって、推奨値ではない
稼働中(2026-09 時点)。 同じ型で組んだ常駐を70本、macOS の launchd で動かしている(60秒ごとの巡回・10分ごとの条件判定・落ちたら起こし直す見張り)。このリポジトリはその運用から個人の記録と対象依存の実装を外し、骨組みだけを書き直したもの。実際に動かしている側のコードは、扱っている記録が個人のものなので公開していない。
MIT