0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

♻️ ネタキューが空になったらClaudeが次のテーマを自分で補充する仕組み

0
Posted at

月10万の大学生だったころは「1本書けた、今週は終わり」だった。今は毎朝8時にlaunchdが1本溜め、気づいたら月商120万を支える連載が止まったことがない。差は才能でも勤勉さでもなく、ネタを自分で補充し続ける環境を1回だけ作ったかどうかだ。


なぜこの仕組みが効くのか

個人で記事を量産しようとすると、大抵は「ネタが尽きたら止まる」という壁にぶつかります。カレンダーにリマインダーを入れてネタ出しの時間を確保しても、その日に気乗りしなければスキップしてしまいます。人間がボトルネックになる設計は、必ずどこかで詰まります。

私がとった戦略は、作業を環境に委ねることでした。記事を書く作業ではなく、「ネタが切れたことを検知してネタを補充する仕組み」と「ネタを記事に変換する仕組み」を一度だけ組み立て、その後は放置する。人間が手を動かすのは、完成した記事を確認してnoteやZennに公開する最後の1アクションだけです。

この記事で紹介する article-daily-stock.sh は、その中核になるスクリプトです。設計の要点は3つあります。

要点1: ストックとデプロイを切り離す

スクリプトの冒頭コメントにある通り、「Zennデプロイ(deploy-next)が詰まっても、ここは止まらない」という設計です(実コード1-11行目)。生成の成否は「~/content/article にストックが書けたか」だけで判定します。記事を公開するジョブ(zenn-daily)は別プロセスで動いており、互いに独立しています。1つのジョブが失敗しても連鎖停止しない構造が、長期運用の安定性を支えています。

要点2: キューをバッファとして使う

ネタは ~/zenn-articles/.topic-queue.json というJSONファイルにオブジェクトの配列として積んであります。現在のキューには16本のネタが待機しています。毎朝8時に先頭の1本を取り出して記事に変換し、完了したらそのネタを done-queue へ移動させます。キューが残っているかぎり、朝8時のジョブは即座に本題へ入れます。

問題はキューが底をついた瞬間です。従来であれば「その日は0本」で終わります。でもそれは避けたかった。1日でも途切れると、「今日も書かなくてよい」という習慣の崩れ方が始まるからです。

要点3: 空になったらClaudeが自分でネタを作る

キューが空になったとき、スクリプトは外部サービスでもAPIでもなく、ローカルで動いている claude -p を呼び出して次の記事テーマを1本だけ自動立案し、キューの先頭に差し込みます。根拠に使うのは「その日の実作業」です。daily brief・メモリファイル・自動化スクリプト群——毎日積み上がっている実体から「技術記事になりえるネタ」をClaudeが見つけてきます。捏造ではなく、実在するファイルとパスを根拠として提示することを指示の中でハードコードしています。

生成されたJSONが必須フィールド検証を通らなければ、done-markerを書かずに終了します。launchdのキャッチアップスロット(10:35)が自動的に同じスクリプトを再実行するため、「補充失敗 → 次スロット再試行」まで含めて1セットの仕組みになっています。


全体の流れ

アスキー図

launchd com.shun.article-daily
├─ 8:00  StartCalendarInterval
└─ 10:35 StartCalendarInterval(キャッチアップ)
         │
         ▼
claude-quota-guard.py  ← Claude Maxトークン枯渇時に即abort
         │
         ▼
run-and-notify.sh      ← 完了/失敗をDiscordへ通知
         │
         ▼
article-daily-stock.sh apply
         │
         ├─ [0] done-marker 確認(当日生成済み?)
         │       YES → audit のみ実行して exit 0
         │
         ├─ [1] 全ストックを audit + 自己修復
         │       サムネ欠落 → gen_note_thumbs.py で再生成
         │       本文不正   → done-queue からネタを取り戻してキュー再投入
         │
         ├─ [2] jq 'length' .topic-queue.json
         │       │
         │       ├─ >= 1 → [4] キュー先頭取得 へ
         │       │
         │       └─ == 0 → [3] 自動立案モード
         │                     │
         │                     ▼
         │                 BRIEF_LATEST(当日のdaily brief md)
         │                 + ~/.claude/memory/ 
         │                 + ~/.claude/scripts/ 実ファイル
         │                     │
         │                     ▼
         │                 claude -p REPLENISH_PROMPT
         │                 --model sonnet --effort high
         │                 --max-turns 20
         │                     │
         │                 JSON検証(slug / title / sources / thumb_title)
         │                     ├─ NG → done-marker 書かず exit 0
         │                     │        ↑ 10:35スロットが拾う
         │                     └─ OK → queue 先頭へ insert
         │
         ├─ [4] キュー先頭 → SLUG / TITLE / EMOJI / NO を取得
         │
         ├─ [5] claude -p で記事執筆(--max-turns 40, 最大1500秒)
         │
         ├─ [6] 検証(title有無・70字以内・1200bytes以上・スタブ語なし)
         │       NG → ファイル破棄 / done-marker 書かず exit 0
         │
         ├─ [7] content/article/articles/ へストック
         ├─ [8] gen_note_thumbs.py でサムネ生成 → thumbnails/
         ├─ [9] coverage.json を upsert
         ├─[10] manifest を ready 化
         ├─[11] queue pop → done-queue へ移動
         └─[12] git push(best-effort・失敗しても done-marker は立つ)

plistの設計:スロットが2本ある理由

~/Library/LaunchAgents/com.shun.article-daily.plist の StartCalendarInterval を見ると、発火時刻が8:00と10:35の2スロットになっています。

<key>StartCalendarInterval</key>
<array>
    <dict>
        <key>Hour</key><integer>8</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
    <dict>
        <key>Hour</key><integer>10</integer>
        <key>Minute</key><integer>35</integer>
    </dict>
</array>

1本目(8:00)が本番スロット、2本目(10:35)がキャッチアップスロットです。スクリプト冒頭のdone-markerチェックにより、当日すでに生成済みなら「audit + 自己修復のみ実行して即exit」します(実コード51-53行目)。

SKIP_GEN=0
[ "$MODE" = "apply" ] && [ -f "$DONE_MARKER" ] && SKIP_GEN=1

この設計のポイントは、「8:00成功 → 10:35は監査のみで終わる」「8:00失敗 → 10:35が本番を引き受ける」という二重安全弁になっていることです。マーカーファイル名は ~/.claude/logs/.article-daily-done-YYYYMMDD という日付サフィックス形式で、7日後に自動削除されます(実コード525行目)。

キュー空検知から補充までの実コード

スクリプトの286-346行目がネタ自動立案の実装です。要点を追って解説します。

ステップ1: 空検知

QLEN=$(jq 'length' "$QUEUE" 2>/dev/null || echo 0)
if [ "$QLEN" -eq 0 ]; then
  log "queue 空 → 実作業からネタ自動立案"

jq 'length' で配列の要素数を取得しています。jq が見つからない場合や構文エラーの場合は echo 0 にフォールバックするため、エラー時も安全にネタ立案モードへ入ります。

ステップ2: コンテキスト収集

BRIEF_LATEST=$(ls -t "$HOME/Desktop"/*brief* \
  "$HOME/Documents/claude-obsidian/wiki/briefs/daily/"*.md \
  2>/dev/null | head -1)

「その日の実作業」を根拠にするため、当日のdaily briefを最新mtime順で探します。ls -t の優先度でDesktop上のbriefとObsidian vault内のbriefを両方スキャンし、最新の1ファイルのみ使います。

ステップ3: Claudeへのプロンプト

プロンプト(REPLENISH_PROMPT)の中で重要なのは制約ブロックです。

# 制約
- 既出slugは禁止(重複ネタNG): ${USED}
- 技術記事として1本で完結する具体的な工夫であること(粒度: 1スクリプト/1仕組み)
- 根拠ファイルは必ず実在パスで2〜4個挙げる(~ 表記)

USED には既存のqueue・done-queue・articlesディレクトリ・coverage.jsonから収集した既出slug一覧が入ります(113-119行目の used_slugs() 関数で生成)。過去に書いた記事と同じスラッグが生成された場合は後続の重複チェックで弾かれます。

Claudeの呼び出しは次のコードです。

TOPIC_JSON=$(run_to 600 "$CLAUDE" -p "$REPLENISH_PROMPT" \
  --strict-mcp-config --mcp-config '{"mcpServers":{}}' \
  --model "${ARTICLE_MODEL:-sonnet}" --effort high \
  --output-format text --allowedTools "Read,Grep,Glob,Bash" --max-turns 20 2>>"$LOG")

run_to 600 は gtimeout 600 のラッパーで、最大10分のタイムアウトを設けています。--strict-mcp-config --mcp-config '{"mcpServers":{}}' でMCPサーバーを完全に無効化し、Claude Code標準ツール(Read/Grep/Glob/Bash)だけを許可しています。モデルは環境変数 ARTICLE_MODEL で上書きでき、未設定時はsonnetを使います。

ステップ4: JSONフェンス除去と検証

TOPIC_JSON=$(printf '%s' "$TOPIC_JSON" | sed -n '/{/,/}/p')
if ! echo "$TOPIC_JSON" | jq -e \
  '.slug and .title and (.sources|length>0) and (.thumb_title|length>0)' \
  >/dev/null 2>&1; then
  log "ABORT: ネタ自動立案に失敗(JSON不正)。marker無しで次スロット再試行"
  notify "ネタ自動立案に失敗。次スロットで再試行。"
  exit 0
fi

Claudeがコードフェンスや前置き文を付けてしまうことを想定して、sed -n '/{/,/}/p' で最初の { から最後の } までを切り出します。検証では slug ・ title ・ sources(1件以上)・ thumb_title(1件以上)の4フィールドが存在することを確認します。検証失敗時は exit 0 で終了しますが、done-markerを書かないことが重要です。done-markerがないため、10:35のキャッチアップスロットが同じスクリプトを再び実行します。

ステップ5: noとprev_slugを機械側で上書きしてからinsert

TOPIC_JSON=$(echo "$TOPIC_JSON" | jq -c \
  --arg no "$NO_NEW" --arg prev "$PREV_NEW" \
  '.no=$no | .prev_slug=$prev')
jq --argjson t "$TOPIC_JSON" '[$t] + .' "$QUEUE" > "$QUEUE.tmp" && mv "$QUEUE.tmp" "$QUEUE"
log "ネタ追加: $NEW_SLUG (no=$NO_NEW)"

no(記事番号)と prev_slug(前作のスラッグ)はモデルが取り違えるリスクがあるため、生成後にシェル側で確定値に上書きします。jq '[$t] + .' でキューの先頭に差し込むため、次の8時か10:35のスロットで即座にこのネタが使われます。>.tmp && mv パターンでアトミックに書き換えているのは、書き込み中にスクリプトが中断されてJSONが壊れることを防ぐためです。

plistのラッパー構造

plistの ProgramArguments を見ると、直接スクリプトを呼ぶのではなく2段のラッパーを経由しています。

<array>
  <string>~/.claude/scripts/claude-quota-guard.py</string>
  <string>--job</string>
  <string>com.shun.article-daily</string>
  <string>--</string>
  <string>/bin/bash</string>
  <string>~/.discord/run-and-notify.sh</string>
  <string>zenn</string>
  <string>Zenn記事ストック生成</string>
  <string>/bin/bash</string>
  <string>~/.claude/scripts/article-daily-stock.sh</string>
  <string>apply</string>
</array>

claude-quota-guard.py はClaude Maxプランのトークン枯渇を事前にチェックします。残量が危機的状態(token-budget-advisor.sh --short が 🔴 または critical を返す)の場合、スクリプト本体を起動せずに即abortします(実コード94-98行目)。これにより「トークンが底をついた状態でClaude呼び出しを連打して無駄に消耗する」という事態を防いでいます。

run-and-notify.sh は処理結果をDiscordへ通知するラッパーです。引数の "zenn" と "Zenn記事ストック生成" がチャンネルと通知タイトルになります。スクリプト本体は通知ロジックを持たず、成功/失敗を問わず結果がDiscordに届きます。

plist自体の設定も記録しておきます。LowPriorityIO: true と Nice: 10 を設定しているため、記事生成中にMac本体の操作が重くなりません。ProcessType: Background と組み合わせることで、スリープ明け直後の高負荷時でもOSがI/Oを後回しにして主タスクを優先します。RunAtLoad: false なので、plistをロードしただけでは起動せず、指定時刻にのみ発火します。

実装を支える細部の設計

前半ではキュー補充の核心ロジックを追いました。実際のスクリプト(528行)にはそれを支える細部の設計がいくつも積み重なっています。「なぜそう書いたか」が分かりにくい箇所を順番に取り上げます。

launchd でのPATH問題を動的に解決する

# article-daily-stock.sh 56-58行目
NODE_BIN=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
[ -n "$NODE_BIN" ] && export PATH="${NODE_BIN}:$PATH"

launchd が起動するプロセスは .zshrc も .bash_profile も読みません。claude コマンドが ~/.nvm/versions/node/v24.13.0/bin/ にインストールされていても、launchd の素のPATHには含まれていません。plistの EnvironmentVariables でPATHを明示的に渡している(plist 8行目)にもかかわらず、スクリプト内でも改めてNVM binを先頭に積んでいます。理由はバージョン固定を避けるためです。plistにハードコードした /v24.13.0/bin は node upgrade のたびに手動で書き直す必要が生じます。sort -V | tail -1 で最新バージョンのbinを動的に取得することで、plistは一切触らなくて済む設計になっています。

caffeinate で「スリープによる中断」を根絶する

# article-daily-stock.sh 61-63行目
if [ -z "${CAFFEINATED:-}" ]; then
  exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi

記事生成はClaude呼び出しを2回(ネタ立案+記事執筆)含めると最長25分かかります。その間にMacがスリープすると、claude -p のHTTP接続が切れてタイムアウト扱いになります。caffeinate -i でアイドルスリープ、-s でシステムスリープをそれぞれ抑制し、exec で自プロセスを置き換えることで「caffeinate の傘の下で元のスクリプトを走らせ続ける」構造を作っています。CAFFEINATED=1 をチェックしているのは無限再帰防止です。この1ブロックを追加してから、スリープ明けの中断ログはゼロになりました。

mkdir ベースのアトミックロックで二重実行を防ぐ

# article-daily-stock.sh 66-75行目
LOCKDIR="$HOME/.claude/locks/article-daily.lock"
if ! /bin/mkdir "$LOCKDIR" 2>/dev/null; then
  oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || true)
  if [ -n "${oldpid:-}" ] && kill -0 "$oldpid" 2>/dev/null; then
    log "別インスタンス実行中(pid=$oldpid) — skip"; exit 0
  fi
  rm -rf "$LOCKDIR"; /bin/mkdir "$LOCKDIR" 2>/dev/null || exit 0
fi
echo $$ > "$LOCKDIR/pid"
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM

8:00スロットの生成が長引いたとき、10:35スロットが同じスクリプトを起動します。ファイルロック(flock)ではなく mkdir をロック機構に採用した理由は、macOS の mkdir がPOSIX保証のアトミック操作だからです。2つのプロセスが同時に mkdir を呼んでも、成功するのは一方だけです。さらにロックディレクトリが残っていた場合でも、中のPIDに kill -0 を当てて「プロセスが本当に生きているか」を確認し、死んでいれば古いロックを掃除して継続します。これはスクリプトが強制終了されたときに生じる stale lock を避ける「ゾンビPIDチェック」です。

Pythonインラインで title 文字数を正確に取る

Zennにはtitleが70字以内という制約があります。これを確認する frontmatter_title_chars 関数(140-165行目)はPythonのインラインスクリプトで実装されています。

# 141-164行目のPythonインライン(要点抜粋)
if (in_frontmatter or idx < 20) and line.startswith("title:"):
    title = line.split(":", 1)[1].strip()
    if len(title) >= 2 and title[0] == title[-1] and title[0] in ("'", '"'):
        title = title[1:-1]
    print(len(title))
    sys.exit(0)

grep | wc -c を使わない理由は引用符とマルチバイトの問題です。Zenn frontmatter のtitle値はシングルクォートで囲まれる場合と囲まれない場合があります。wc -c はバイト数を返すため、日本語1文字(3バイト)を3文字と数えてしまいます。wc -m を使えば文字数にはなりますが、引用符の除去が別途必要になり、記述が複雑になります。Pythonで frontmatter をパースし、引用符を除いた純粋な文字列の len() を返す設計にすることで、日本語タイトルも引用符あり/なしもまとめて正確に測れます。

秘密スキャンより先にパスサニタイズを走らせる

# article-daily-stock.sh 431-446行目(順序が肝)
# ①先にサニタイズ
for _f in "$ART" "$ARTICLES/$SLUG.md"; do
  [ -f "$_f" ] || continue
  /usr/bin/sed -i '' -E 's#/Users/[A-Za-z0-9._-]+/#~/#g' "$_f"
done

# ②その後で秘密スキャン
if grep -nEi 'AKIA[0-9A-Z]{16}|(secret|api_key|...)[[:space:]]*[:=]...' "$ART" >/dev/null; then
  log "ABORT: 秘密らしき値混入 → 中止"
  rm -f "$ART"; exit 1
fi
# ③実ホームパスが残っていたら中止
if grep -q "/Users/" "$ART"; then
  log "ABORT: 実ホームパス(/Users/)混入 → 中止"
  rm -f "$ART"; exit 1
fi

生成プロンプトで「~ 表記で書け」と明示しても、Claudeが実パス(/Users/…)を本文に書いてしまうことがあります。この場合、スクリプトはいきなりABORTするのではなくまず機械的サニタイズを試みます。/Users/ が公開記事の意味に関わることはないため、~ に置き換えても本文の意味は壊れません。サニタイズ後になおAWSキー形式(AKIA…)やsecret代入パターンが残っていた場合だけABORTします。「直せるものは直す・直せないものだけ止める」という考え方で、不要な再試行回数を最小限にしています。

done-marker を git push の前に立てる理由

# article-daily-stock.sh 509行目と524行目
# キューをpopした直後——git push の前——に立てる
touch "$DONE_MARKER"   # ← 511行目

run_to 120 git push -q 2>>"$LOG" || log "WARN: push失敗(ストックは確保済)"

# push後にも念のため(べき等な重複touchは無害)
touch "$DONE_MARKER"   # ← 524行目

done-markerが2回 touch されているのは意図的です。1回目はgit pushより前に立ちます。もしpushが失敗してスクリプトが終了した場合、done-markerがないと10:35スロットがdone-markerを見つけられずに再び記事生成を実行します。同じSLUGで再生成すれば既存ファイルを上書きし、キューも2回popされます。ストックへのファイル書き込みが完了した時点が「生成の完了」であり、git pushはあくまでbest-effortの後処理です。この順序だけで、push失敗による重複生成事故を完全に防げます。


私が詰まった3つの落とし穴

設計を考えると「こうすれば動く」は頭で分かります。でも実際に動かすと、想像しなかった場所で詰まります。私が実際に踏んだ失敗を、症状→原因→直し方の順で3つ書きます。

落とし穴①:Claudeが必ずJSONをコードフェンスで包んでくる

症状

ネタ自動立案が毎回 ABORT: JSON不正 で終わる。ログを見ると TOPIC_JSON の中身が次のようになっていた。

```json
{
  "no": "17",
  "slug": "caffeinate-wrapper",
  ...
}

**原因**

`claude -p` のプロンプトに「コードフェンスなし・JSONオブジェクト1個だけ出力しろ」と明記しても、かなりの確率でバッククォート3つと `json` タグで囲んで出力してきます。このまま `jq -e` に渡すとパースエラーで即落ちます。

**直し方**

```bash
TOPIC_JSON=$(printf '%s' "$TOPIC_JSON" | sed -n '/{/,/}/p')

sed -n '/{/,/}/p' は「最初の { が現れた行から最後の } が現れた行まで」を出力します。コードフェンス・前置き説明・後置きテキストをすべて剥ぎ取って、JSONオブジェクト部分だけを残します。JSONの中に { が複数ネストしていても最終の } が末尾になるので、正しく機能します。これを入れてからJSON不正でのABORTはほぼゼロになりました。教訓:モデルへの「フォーマット厳守」の指示は信頼してはいけない。出力を機械的に正規化するコードを必ず書く。

落とし穴②:8:00と10:35が重なって同じ記事を2本生成した

症状

翌朝、~/content/article/articles/ を確認すると同じ連番(17-caffeinate-wrapper.md)で内容の微妙に違うファイルが2本あった。キューも2つ消えており、done-queueに同じSLUGのエントリが2件あった。Discordにも「完了」通知が2回届いていた。

原因

8:00に起動した生成が25分かかり、10:35スロットが起動した時点でまだ走っていました。二重実行ロックを入れる前の初期バージョンでは、両プロセスが並行してキューの先頭を読んでいました。8:00プロセスがキューをpopする前に10:35プロセスがキューを読んでしまい、両者が同じSLUGで別のClaude呼び出しを走らせた結果、後からファイルを書いたプロセスが既存ファイルを上書きして2つのdone-queueエントリを残しました。

直し方

mkdir ベースのロック(65-75行目)を追加しました。mkdir はPOSIX保証のアトミック操作で、2つのプロセスが同時に呼んでも片方だけが成功します。さらに done-marker チェック(51-53行目)が SKIP_GEN=1 をセットし、当日すでに生成済みなら監査だけ実行して即exitするフローを整備しました。重複が発覚したのは投稿後でしたが、done-queueの generated_at タイムスタンプを見れば2秒差で2件あることが分かり、原因の特定は即座にできました。

落とし穴③:壊れた記事がキューに戻り続けて無限ループした

症状

数日後、同じSLUGの記事生成が毎朝走るのにすべて ABORT: 生成本文が不完全 で終わっていた。done-queueには同じSLUGのエントリが毎日1件ずつ積まれ、記事ファイルは毎回破棄されていた。同時にトークン消費ログが通常の3倍になっていた。

原因

audit_repair 関数(230-277行目)は「本文不完全と判定した記事をdone-queueから取り戻してキューへ再投入する」設計になっています(261行目)。しかし再生成が走るたびに同じ壊れ方でABORTし、またキューへ再投入する……というループに入っていました。bump_attempt 関数は再試行回数を $ATTEMPTS_FILE に記録して MAX_ATTEMPTS=2 を超えたら needhuman 配列に逃がす仕組みになっていましたが、$ATTEMPTS_FILE(JSONファイル)がディスク書き込み中の中断で壊れた状態になっており、常に n=0 を返し続けていたことが根本原因でした。

直し方

bump_attempt の書き込みを .tmp → mv のアトミックパターンに変えました。

# article-daily-stock.sh 224-226行目
jq --arg s "$slug" --argjson n "$n" '.[$s]=$n' \
  "$ATTEMPTS_FILE" > "$ATTEMPTS_FILE.tmp" 2>>"$LOG" \
  && mv "$ATTEMPTS_FILE.tmp" "$ATTEMPTS_FILE"

さらに137行目に [ -f "$ATTEMPTS_FILE" ] || echo '{}' > "$ATTEMPTS_FILE" の初期化行を追加し、ファイルが壊れていても次回起動時に空オブジェクトから再出発できるようにしました。MAX_ATTEMPTS=2 を超えたSLUGは needhuman 配列に積まれ、_NEEDS-FIX.txt への書き出しとmacOS通知(267-268行目)がセットで走ります。この修正後、無限ループは完全になくなりました。


3つの落とし穴はすべて「単体テストでは見えないタイミングの問題」か「状態ファイルの壊れ方の問題」でした。フローを机上で追うだけでは発見できません。同じ仕組みを作るなら、まず dry モードで手動実行して各ステップのログを追うことをお勧めします。bash article-daily-stock.sh dry と引数を渡すと、キューのpopもgit pushも起きずストックファイルの書き込みだけ行って終わります(498-500行目)。本番に繋ぐ前にこのモードで意図的に異常系を作り、「JSON壊し・記事ファイル削除・ロックファイル残留」それぞれの挙動を確認するのが最短ルートです。

つまずきポイント

p2で取り上げた「JSON フェンス」「二重実行」「無限ループ」の3件に加えて、実際の運用で詰まったポイントを網羅的に列挙します。

① launchd の ~ 展開は行われない

plist を書き始めた最初の頃、ProgramArguments に ~/.claude/scripts/article-daily-stock.sh と書いたところ、launchd が「ファイルが存在しない」と判断して即 exit しました。launchd は ~ を展開しません。plist は必ず /Users/<username>/… の絶対パスで書く必要があります(実際の plist でもすべての <string> が絶対パスになっているのはこのためです)。スクリプト内部ではホームを $HOME 変数で参照できますが、plist の ProgramArguments と EnvironmentVariables の値には絶対パスしか使えません。

② gtimeout が入っていない Mac では run_to が何もしない

スクリプトは GNU coreutils の gtimeout をタイムアウトラッパーとして使います(82-84行目)。/opt/homebrew/bin/gtimeout が存在しない場合は TIMEOUT_BIN="" になり、run_to() は単に "$@" を素通りさせます。brew install coreutils が済んでいない環境では run_to 1500 claude -p … のタイムアウトが機能せず、Claude 呼び出しが際限なく走り続けます。gtimeout --version で確認してから配線してください。

③ スリープ明けのネット疎通待ちを入れなかった

MacBook をスリープさせたまま翌朝 8:00 になるとスリープから目覚めてスクリプトが起動しますが、Wi-Fi の再接続が間に合わずに claude -p が即座にエラー終了していました。スクリプト 87-91 行目に nc -z -G 3 1.1.1.1 443 を最大 18 回(90秒)ループさせる疎通待ちが入っているのはこのためです。audit モードはネットを使わないのでループをスキップしています。

④ トークン枯渇時に Claude を呼び続けて残量を溶かした

Claude Max プランは 5 時間枠・7 日枠の両方が存在します。週の後半に記事生成が続くと 7 日枠が逼迫し、claude -p が途中でレートリミットを返すようになります。最初はエラーを無視して次スロットで再試行し続けたため、「止まらない → 毎回途中で落ちる → また試みる」というループで残量をさらに削る事態になりました。現在は 93-98 行目の token-budget-advisor.sh --short が 🔴 か critical を返した時点でジョブ本体を起動せず終了します。Claude 呼び出しの前にトークン残量を確認するのは必須の前処理です。

⑤ ストック記事ファイルのパスが NO の桁によってソート順が崩れた

next_no() 関数(122-130行目)は ~/content/article/articles/ 内のファイル名から最大番号を読んで +1 します。初期は番号を 1、2、… と1桁で作っていたため、ls のソートで 10 < 2 になり番号の採番が狂いました。printf '%02d' で必ずゼロ埋め2桁にすることで解消しています。将来99本を超えるなら3桁に変更が必要です。

⑥ article_ok() のスタブ語チェックが誤検知した

179行目の grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)' は記事本文の中にスタブキーワードが含まれていないか確認します。ある回、「タイムアウトを request timed out というエラーで検知する実装を紹介する記事」を書いたところ、本文中に request timed out という文字列が登場してしまい、正常に生成された記事を ABORT: 生成本文が不完全 で弾いてしまいました。スタブ語チェックのパターンはできるだけ「本文の例文として現れにくい語」に絞る必要があります。

⑦ git push の失敗を exit 1 にしていた時期があった

初期実装では git push に失敗したらスクリプト全体を exit 1 にしていました。push 失敗の理由が「オフライン」「コンフリクト」問わず全部失敗扱いになるため、done-marker が立たず 10:35 スロットが同じ記事を再生成し始めました。現在は 518行目の通り || log "WARN: push失敗…" としてエラーを警告ログに落とし、スクリプトを続行する設計です。生成の完了とgit pushの成否は切り離して考えることが重要です。

⑧ coverage.json が肥大してキュー削除済みのエントリが残り続けた

記事をいくつか書き直して SLUG が変わった際、古いエントリが coverage.json に残り続けました。audit_repair() の終盤(270-276行目)に stale 掃除のブロックがありますが、当初これを入れておらず、200件を超えたあたりで jq 処理が重くなりました。coverage.json の掃除は「ストックディレクトリに実在するファイル + キューに残っているスラッグ」だけを残し、それ以外を削除する形が正解です。

⑨ ATTEMPTS_FILE の初期化を忘れてゾンビリトライが発生した

bump_attempt() は ATTEMPTS_FILE(.article-repair-attempts.json)に再試行回数を記録します。この初期化を [ -f "$ATTEMPTS_FILE" ] || echo '{}' > "$ATTEMPTS_FILE" (137行目)で行っていますが、初期実装ではこれがなく、ファイルが存在しない場合に jq がエラーを返して n が常に 0 になっていました。結果、MAX_ATTEMPTS=2 を永遠に超えないことになり、壊れた記事が毎朝キューに再投入され続けました。状態ファイルはスクリプト冒頭で必ず存在を保証してください。

⑩ sips コマンドが macOS 専用であることを忘れて Linux テスト環境で落ちた

thumb_ok() 関数(185-188行目)はサムネのピクセル幅を sips -g pixelWidth で確認します。このコマンドは macOS のみに存在し、Linux や Docker コンテナでは command not found になります。CI で動作確認しようとしたときに初めて気づきました。launchd 前提の仕組みであるため実害はありませんが、別環境で試す場合はこのコマンドのスタブが必要です。

⑪ no フィールドが文字列で渡されてゼロ詰め変換が失敗した

NO=$(echo "$TOPIC" | jq -r '.no // ""') で取得した番号を printf '%02d' "$((10#$NO))" に通すとき、no が空文字や "01" のような文字列になっているとシェルの算術評価が 10# を解釈できずに失敗することがありました。355行目の [ -z "$NO" ] || ! [[ "$NO" =~ ^[0-9]+$ ]] && NO=$(next_no) で「数字以外が来たら next_no() で採番し直す」というガードが入っているのはこのためです。


ベストプラクティス

実際の運用で検証した設計原則をまとめます。

1. 生成とデプロイを独立させる

ストックへの書き込みとZenn への公開を同じジョブに混ぜない。生成の完了は「~/content/article/articles/ にファイルが書けたか」だけで判定し、git push は best-effort の後処理として扱います(スクリプト冒頭コメントの通り)。デプロイが詰まっても生成は止まらない設計が長期運用の安定の源泉です。

2. done-marker を git push の前に立てる

「ストックへの書き込みが完了した瞬間」が生成の完了です。509行目の touch "$DONE_MARKER" は git push より前に置かれています。これにより push が失敗してもキャッチアップスロットが重複生成を行いません。524行目の2回目の touch は push 後の念押しですが、1回目が全体の設計を守る要です。

3. .tmp && mv パターンで状態ファイルを守る

topic-queue.json、coverage.json、ATTEMPTS_FILE のすべての書き込みは jq … > "$FILE.tmp" && mv "$FILE.tmp" "$FILE" のアトミックパターンで行います。中断時に中途半端な JSON が残ることを防ぎ、次回起動時の jq パースエラーを根絶します。&& を忘れて > file && mv と書くと、jq が失敗しても空ファイルに書き換えてしまうので注意です。

4. mkdir ベースのアトミックロックを使う

flock は macOS の /bin/flock が存在しない環境で使えません。/bin/mkdir はPOSIX保証のアトミック操作で、macOS 上で確実に動きます。ロックディレクトリ内に PID を書き、kill -0 でゾンビ PID を検出して stale lock を自動掃除するパターン(65-75行目)が二重実行防止の完全形です。

5. リトライは OS(launchd)に委ねる

失敗時に sleep ループで自分でリトライするより、done-marker を書かずに exit 0 して次のスロットに拾わせる設計の方がシンプルです。plist の StartCalendarInterval に 8:00 と 10:35 の2スロットを設けることで、「本番 → 失敗 → 2時間35分後に自動再挑戦」という二重安全弁が OS レベルで完成します。スクリプト側のコードは最小のままです。

6. モデルの出力を信用せず必ず正規化する

「JSON だけ出せ」と明記しても claude -p はコードフェンスと前置き文を付けます。sed -n '/{/,/}/p' で最初の { から最後の } までを切り出す正規化(332行目)を必ず入れてください。これを入れてから JSON 不正 ABORT はほぼゼロになりました。同様に、no と prev_slug のようにモデルが取り違えやすいフィールドは生成後にシェル側で確定値へ上書きします(342-343行目)。

7. ABORT の前に機械的修復を試みる

実ホームパス(/Users/…)の混入はプロンプトで防止を指示していても発生します。これを即 ABORT にすると再試行コストが高い。スクリプトは sed -i '' -E 's#/Users/[A-Za-z0-9._-]+/#~/#g' でまず自動修正を試み(434-437行目)、それでも secret や AKIA… パターンが残った場合だけ中止します。「直せるものは直す・直せないものだけ止める」の原則で不要な再試行を減らします。

8. トークン残量をスクリプトの先頭でチェックする

Claude 呼び出しを始めてから残量不足で失敗すると、それまでの準備がすべて無駄になります。93-98行目のように、claude -p を呼ぶ前に token-budget-advisor.sh --short で残量を確認し、🔴 ならジョブを終了する判断を最初に行います。これにより「残量が少ない週の後半に Replenish + 記事生成の2回の呼び出しで枠を一気に使い切る」という事態を回避できます。

9. caffeinate -i -s exec で長時間プロセスを保護する

最大 25 分かかる処理(ネタ立案 10 分 + 記事執筆 25 分)の途中で Mac がスリープすると HTTP 接続が切れてタイムアウト扱いになります。exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@" の1行(62行目)でスクリプト自体を caffeinate の傘の下で再起動します。exec でプロセスを置き換えるため余計なサブシェルが増えません。CAFFEINATED=1 の確認は無限再帰を防ぐためのものです。

10. push 先の owner を ALLOWED_OWNER で明示的に制限する

git push は ALLOWED_OWNER="bokuwalily"(39行目)と一致する GitHub ユーザーのリポジトリへのみ実行します(512-522行目)。フォーク済みのリポジトリや間違えてクローンしたリポジトリへ誤 push するのを防ぐための安全弁です。自分の記事が他者の名義リポジトリへ公開されるのを防ぐために必ずチェックしてください。

11. dry モードで異常系を手動テストしてから launchd に繋ぐ

bash article-daily-stock.sh dry は キューのpop も git push も発生させず、ストックファイルの書き込みだけを行います(496-500行目)。plist を launchctl load する前に dry モードで「JSON を壊す」「記事ファイルを削除して audit を走らせる」「ロックファイルを残したまま起動する」という3つの異常系を手で試すのが最短の動作確認ルートです。本番に繋いでから異常系の挙動を発見しようとすると、1スロット24時間待ちのループになります。

12. LowPriorityIO + Nice 10 でバックグラウンドを静かにさせる

plist に LowPriorityIO: true、Nice: 10、ProcessType: Background を設定することで、記事生成中にメインの作業が遅くなりません。Nice 10 は CPU の優先度を下げ、LowPriorityIO はディスク I/O を OS が後回しにします。Claude Code を同時に使いながら記事生成が走っても、操作の重さを感じることはありません。

13. MIN_ARTICLE_BYTES=1200 でスタブ記事を確実に弾く

Claude が何らかの理由でタイムアウトしたとき、「記事が生成できませんでした」という数十バイトのエラー文だけが書き込まれることがあります。article_ok() 関数は stat -f%z でバイト数を確認し、1200 バイト未満ならスタブ扱いにします(176-177行目)。MIN_ARTICLE_BYTES はコンテキスト上部で定数化してあるので、記事の最低品質を調整するときはここを変えるだけで全体に反映されます。

14. 再試行回数を ATTEMPTS_FILE に記録して人間にエスカレーションする

audit_repair() が壊れた記事を再生成キューへ戻すとき、bump_attempt() で試行回数を記録します。MAX_ATTEMPTS=2 を超えると needhuman 配列に積まれ、_NEEDS-FIX.txt への書き出しと macOS 通知(266-268行目)がセットで走ります。自動修復が永遠に循環しないよう、人間へのエスカレーション閾値を設けることは機械による修復ループの必須設計です。

15. plist の PATH には nvm バージョンをハードコードせず、スクリプト側で動的解決する

plist の EnvironmentVariables.PATH に /Users/…/.nvm/versions/node/v24.13.0/bin をハードコードすると、node をアップグレードするたびに plist も書き直す必要が生じます。スクリプト 56-58 行目の ls -d ~/.nvm/versions/node/*/bin | sort -V | tail -1 で最新 node の bin を動的に取得することで、plist は一度書いたら触らなくて済む設計になります。


まとめ

この記事で紹介した仕組みを整理すると、「キューが空になったらClaudeが次のネタを自分で立案してキューへ足す」は5つの判断の積み重ねで成立しています。

  • 生成とデプロイを切り離す:片方が止まっても連鎖しない
  • 失敗したら done-marker を立てずに終了する:OS がリトライを担う
  • 出力を正規化してから検証する:モデルの出力形式を信用しない
  • 状態ファイルはアトミックに書く:中断で壊れない
  • 自動修復には上限を設けて人間へ引き渡す:ループが収束しないときの出口を作る

.topic-queue.json には現在16本のネタが待機しています。毎朝8時に launchd が1本消費し、0になった翌朝に Claude がその日の実作業(daily brief・memory・スクリプト群)を根拠として次のネタを立案してキューへ差し込みます。生成された JSON が4フィールド検証を通過しなければ done-marker を書かずに終了し、10:35のキャッチアップスロットが拾います。この「補充失敗→次スロット再挑戦」まで含めて1つのセットです。

Zenn への公開は別プロセスの zenn-daily ジョブが担当します。article-daily-stock.sh が把握するのは「~/content/article/articles/ に原稿と、~/content/article/thumbnails/ にサムネが揃ったか」だけです。この分業があるため、この記事シリーズは1日も途切れることなく積み上がっています。

月商120万の収益構造のうち、連載という形での「知識の継続発信」は最もコストパフォーマンスの高い資産形成の手段です。ネタが尽きたから書けない、という状態は、仕組みで解消できます。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?