LaunchDが朝5時に起動するたびに、私が寝ている間に収益化記事が生えていました。
前回(第1回)では、Claude Codeを核にした記事生成の基本設計と、generate.sh が1本の記事ドラフトを作るまでの骨格を解説しました。今回は「収益化リンクをどう確実に埋めるか」「楽天APIが失敗したときにどう逃げるか」「何度実行しても1日5本で収束する冪等設計」「audit-heal.shによる自己修復ループ」という、システムを本当に動かし続けるための後半部分をすべて公開します。
なぜこの仕組みが効くのか
「作業量 × 単価」という呪縛から逃げる
月10万の大学生だったころ、副業でコンテンツを書いていた私の収入方程式は単純でした。「時間×単価」です。月60万まで伸ばせたのは、掛け持ちで稼働時間を限界まで積み上げたからであって、仕組みで稼いでいたわけではありませんでした。
会社都合で解雇されたとき、収入は一瞬でゼロになりました。時間を売る副業は、売る先がなくなると即座に崩壊します。そこで気づいたのが、「稼ぐ環境を建てること」と「稼ぐ作業をすること」はまったく別の活動だということです。
半年間、Claude Codeで自律環境を組み続けた結果、今の月商120万の大部分は私が作業していない時間に積み上がっています。このアフィリエイトファクトリーはその中核のひとつです。
副業ライターが直面する構造的な天井
アフィリエイト記事で稼ぐ最大のボトルネックは、「書く」というアクションです。1本8,000〜10,000字のレビュー記事を書くには、調査込みで最低3〜4時間かかります。それを毎日3〜5本続けることは、専業でなければ物理的に不可能です。
多くのライターはここで「どうすれば速く書けるか」という方向に最適化します。テンプレートを作る、リサーチをAIに任せる、音声入力を使う。いずれも有効ですが、天井が見えています。1日の作業時間は有限だからです。
アプローチを根本から変えると、問いが変わります。「どうすれば速く書けるか」ではなく、「どうすれば自分が書かなくて済むか」。この問いに正面から答えたのが、今回のシステムです。
「環境」とは何か ― 寝ている間に動くということ
このシステムをひとことで表現すると、「LaunchDが朝・昼・夜にdaily.shを叩き、今日まだ足りない本数だけ記事を生成・公開する」です。
ポイントは私が何もしなくてよいという点ではありません。正確には、私が関与するとシステムが壊れるという設計になっている点です。手動でファイルを足したり消したりすると、冪等性が崩れます。launchdのスケジュールが自動で回ることを信頼して、人間は触らない。この割り切りがシステムを安定させています。
朝5時にlaunchdがdaily.shを叩きます。記事が生成されてはてなブログに投稿され、監査ログが残り、問題があればmacOSの通知センターに警告が届く。私はその通知を7時に起きて確認するだけです。確認に使う時間は3分以下です。
冪等設計という考え方
冪等(べきとう) は、「何度実行しても結果が同じになる」という性質です。daily.shのコメントにも明記されています。
# 1日複数回実行される自己回復ジョブ。「今日まだ公開できていない本数」だけを
# 生成→公開する冪等設計。朝が使用量制限等で空振りしても、昼/夜の再実行が
# 自動で残りを埋めるため、何度走らせても1日ちょうど TARGET 本で収束する。
これはただのコメントではなく、設計の核心です。朝の実行でClaude Codeのレート制限に引っかかり0本しか生成できなくても、昼の実行が不足分を補充します。昼も失敗すれば夜が補充する。どのタイミングで何回実行しても、1日の終わりには目標本数に収束します。
この設計がなければ、朝の実行が失敗するたびに「今日はダメだった」で終わります。冪等設計があれば、部分的な失敗はシステムが自動で吸収します。
Claude Codeを「道具」として使うとはどういうことか
generate.shの中でClaudeを呼び出す核心部分は次の1行です(実際のスクリプト272行目)。
RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" --allowedTools WebSearch \
--model sonnet --permission-mode auto </dev/null 2>/dev/null)
</dev/nullでstdinを閉じ、--permission-mode autoでWebSearch使用時の許可プロンプトをバイパスし、timeout "$GEN_TIMEOUT"でハング時に強制終了する。コメントには「</dev/null 必須: 無いとclaude -p がstdinを3秒待ってから進む(launchdではttyなしで毎回発生)」と書いてあります。launchd環境特有のハマりどころです。
GEN_TIMEOUTは環境変数で上書きできますが、デフォルトは1200秒(20分)です。8,000〜10,000字の記事+競合3製品のWebSearch込みで生成するため、これくらい確保しないと長文の途中でkillされ、空応答→0本公開という最悪の結果になります。コスト的には高く見えますが、1本あたりの記事単価と比べれば何も問題ありません。
全体の流れ
システム全体のフロー図
launchd (朝・昼・夜、1日複数回)
│
▼
daily.sh
│ NEED = TARGET - 本日公開済 - ドラフト残 ← 冪等計算
│ NEED=0 なら生成スキップ
│
├─ [NEED > 0] generate.sh × NEED 本
│ │
│ ├─ Claude Code claude -p (timeout 1200s, 最大3回リトライ)
│ │ └─ WebSearch で製品調査 + 競合3製品比較
│ │
│ ├─ resp_is_valid() バリデーション
│ │ └─ PRODUCT:行なし / エラー文含む / 400文字未満 → 失敗扱い
│ │
│ ├─ rakuten_affiliate_url() ← 楽天APIで商品リンク取得
│ │ ├─ 資格情報あり → API検索 (resolve: 末尾語削り戦略)
│ │ │ ├─ 命中 + ブランド一致 → affiliateUrl 取得
│ │ │ ├─ 400 "keyword is not valid" → 語を削って再挑戦
│ │ │ └─ 全滅 → hgc 検索リンクfallback (報酬乗る)
│ │ └─ 資格情報なし → 素の検索URL (報酬ゼロ注意)
│ │
│ ├─ postprocess_body(): 素リンク・プレースホルダー → アフィリリンク全差替
│ │
│ └─ ~/Desktop/アフィリ記事/<YYYYMMDD_HHMMSS>.md
│
├─ post-to-hatena.sh --publish --all
│ ├─ posted-hatena.log でスキップ判定 (冪等)
│ ├─ 壊れ記事 (不明な商品 / Request timed out) スキップ
│ ├─ blogsync post --title "$title" bokuwalily.hatenablog.com
│ └─ 公開済み → published/ にアーカイブ移動
│
└─ audit-heal.sh
├─ 壊れ記事を Desktop キューから削除
├─ published/ の全記事を hb.afl.rakuten.co.jp 含有チェック
├─ 公開数 < TARGET → ⚠ 未達警告
└─ 問題あり → osascript macOS通知 + logs/audit-YYYY-MM-DD.log
daily.sh ― 冪等計算の実装
daily.sh の核心は10行に満たない NEED 計算です。実際のコード(16〜23行目)を見てください。
# 今日すでに公開できた本数(published/ の本日プレフィックス)
PUB_TODAY=$(find "$ARCHIVE" -maxdepth 1 -name "${TODAY}_*.md" 2>/dev/null | wc -l | tr -d ' ')
# Desktop直下に残っている未公開ドラフト(持ち越し+前段で作ったが未投稿の分)
DRAFTS=$(find "$OUT" -maxdepth 1 -name '*.md' 2>/dev/null | wc -l | tr -d ' ')
# 目標到達に必要な新規生成本数 = 目標 − 本日公開済 − 手元ドラフト
NEED=$((TARGET - PUB_TODAY - DRAFTS))
[ "$NEED" -lt 0 ] && NEED=0
echo "[daily] $TODAY $(date '+%H:%M') 本日公開済: ${PUB_TODAY}本 / ドラフト: ${DRAFTS}本 / 目標: ${TARGET}本 → 生成: ${NEED}本"
TARGETはスクリプト上部でTARGET=5とハードコードされています(audit-heal.sh では環境変数 ${AFFILIATE_FACTORY_TARGET:-3} でデフォルト3本として外部から変更可能な設計です)。
重要なのは「ドラフト残」を NEED 計算に含めている点です。前回の実行で生成まで完了したが投稿に失敗した記事が Desktop に残っていれば、次の実行では生成を増やさず、投稿だけ再試行します。これにより「生成コスト(Claude APIの消費)」と「投稿リトライ」を分離できています。
generate.sh ― Claude呼び出しと3回リトライ
generate.sh の生成ループ(269〜276行目)は、1本の記事生成に最大3回のリトライを設けています。
for attempt in 1 2 3; do
RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" --allowedTools WebSearch \
--model sonnet --permission-mode auto </dev/null 2>/dev/null)
if resp_is_valid "$RESP"; then break; fi
echo "[generate] 生成失敗(試行${attempt}/3)。再試行します…" >&2
RESP=""
done
resp_is_valid() の判定ロジック(251〜257行目)も具体的です。
resp_is_valid() {
local r="$1"
[ -z "$r" ] && return 1
# PRODUCT行が無い/タイムアウト等のエラー文/極端に短い応答は失敗扱い
printf '%s' "$r" | grep -q '^PRODUCT:' || return 1
printf '%s' "$r" | grep -qiE 'request timed out|error:|rate limit|usage limit' && return 1
[ "$(printf '%s' "$r" | wc -c | tr -d ' ')" -lt 400 ] && return 1
return 0
}
3回すべて失敗した場合、generate.sh は壊れ記事を書かずに exit 1 で終了します(280〜282行目)。壊れた状態でファイルを書き出すとキューが汚染され、後続の audit-heal.sh に掃除コストが発生します。そのコストを事前回避するための判定です。
プロンプトの中では商品名の1行目出力フォーマット「PRODUCT: <正式商品名>」を厳命しています。この1行があることで商品名の抽出(285行目)と本文の切り出し(287〜289行目)が確実に行えます。モデルに曖昧な出力をさせると後段のパースが全部崩れるため、出力フォーマットの強制は必須です。
楽天API 400エラーと末尾語削り戦略
楽天APIの最大のハマりどころは、400 Bad Request: "keyword is not valid" エラーです。"Narwal Freo Z Ultra" のような製品名に含まれる "Z" や "Ultra" といった単独トークンが、楽天の検索エンジンに弾かれます。
これを解決するのが resolve() 関数(152〜179行目)の「末尾語を1語ずつ削って再挑戦する」戦略です。
def resolve():
words = product.split()
brand = words[0].lower() if words else ""
tried = set()
for n in range(len(words), 0, -1):
keyword = " ".join(words[:n]).strip()
if not keyword or keyword in tried:
continue
tried.add(keyword)
try:
result = fetch(keyword)
except Exception as exc:
print(f"[generate] 楽天API検索に失敗({keyword}): {exc}", file=sys.stderr)
return None
if result:
if not brand or brand in (result["name"] + " " + result["url"]).lower():
return result["url"]
# ブランド不一致=別商品に化けた。
print(f"[generate] 候補がブランド不一致({keyword}→{result['name'][:30]})。検索リンクへ。", file=sys.stderr)
return None
time.sleep(1.0)
return None
"Narwal Freo Z Ultra" → "Narwal Freo Z"(400)→ "Narwal Freo"(命中)という流れです。ただし語を削りすぎると「Narwal」単独で全然別の高レビュー商品が引っかかる可能性があります。そのため ブランド名(先頭語)が取得商品名またはURLに含まれるかどうか を brand in (result["name"] + " " + result["url"]).lower() で検証し、一致しなければ「別商品に化けた」と判定してそれ以上削るのをやめます。
429(レート制限)は time.sleep(1.5) を挟んで最大2回再試行します(130〜148行目の fetch() 内)。
APIが完全に取れないときの hgc フォールバック
商品個別リンクがどうしても取れないとき、resolve() は None を返します。その後の処理(182〜190行目)が本質的なフォールバックです。
affiliate_url = resolve()
if not affiliate_url:
# 個別商品が取れない時は、アフィリ計測付き検索リンク(hgc)にフォールバック=必ず報酬が乗る。
search_url_enc = quote(search_url, safe="")
affiliate_url = (
f"https://hb.afl.rakuten.co.jp/hgc/{affiliate_id}/?pc={search_url_enc}&m={search_url_enc}"
)
print(f"[generate] 商品個別リンクを取得できず検索リンクにフォールバック: {product}", file=sys.stderr)
print(affiliate_url)
hb.afl.rakuten.co.jp/hgc/ は楽天アフィリエイトのアフィリ計測付き検索リンクです。個別商品へのリンクではなく「この商品名で楽天市場を検索した結果ページ」へのリンクになりますが、報酬は乗ります。
環境変数 RAKUTEN_APPLICATION_ID / RAKUTEN_ACCESS_KEY / RAKUTEN_AFFILIATE_ID のいずれかが空の場合、API呼び出し自体を行わず、素の検索URL(https://search.rakuten.co.jp/search/mall/〜)にフォールバックします(83〜87行目)。この状態では報酬がゼロになります。.env の設定ミスで気づかずに運用してしまうのがアフィリ収益ゼロの典型的な原因です。
postprocess_body ― 素リンクをアフィリリンクへ全差替
Claude Codeが生成した記事本文には、素の楽天検索URLが含まれることがあります。プロンプトでアフィリリンクを入れるよう指示しても、モデルが非アフィリのURLを書いてくる場合があります。これをそのまま公開すると報酬がゼロになります。
postprocess_body()(201〜246行目)はこの問題を後処理で根治します。
# 2) claudeが本文に書いた実リンク [楽天で「…」を探す](任意URL) を、正しいアフィリリンクに丸ごと差し替える。
# (これをやらないと claude が書いた非アフィリの検索URLがそのまま残る=報酬ゼロになる)
rakuten_md_link_re = re.compile(r"\[楽天で「[^」]*」を探す\]\([^)]*\)")
body = rakuten_md_link_re.sub(lambda m: link, body)
# 3) 念のため、楽天ドメインを指す素のmarkdownリンクも差し替える
rakuten_any_re = re.compile(r"\[[^\]]+\]\((?:https?:)?//[^)]*rakuten\.co\.jp[^)]*\)")
body = rakuten_any_re.sub(lambda m: link, body)
さらにプレースホルダーへの対応もあります(219〜224行目)。Claudeが「(▼楽天で「〇〇」を検索してリンクを貼る)」のようなプレースホルダーを書いてくることがあり、これも正規表現で検出して置換します。
3段階の差替ロジックにより、どんな形でリンクが記述されていても最終的には正しいアフィリエイトURLに収束します。これが「収益化リンクの確実な埋め込み」の実体です。
はてなブログへの冪等投稿
post-to-hatena.shの--allモードは、posted-hatena.log でスキップ判定を行います(40〜61行目)。一度投稿したファイルのフルパスをログに記録し、次の実行時に同じファイルが残っていても二重投稿しません。
for f in "$OUT"/*.md; do
[ -e "$f" ] || continue
if /usr/bin/grep -qxF "$f" "$POSTED_LOG"; then continue; fi
# 生成失敗の残骸は投稿しない
if /usr/bin/grep -qE '不明な商品|Request timed out' "$f"; then
echo "[hatena] スキップ(生成失敗の残骸): $f" >&2; continue
fi
if post_one "$f"; then
echo "$f" >> "$POSTED_LOG"; found=$((found+1))
[ -z "$DRAFT_FLAG" ] && mv "$f" "$ARCHIVE/" && echo "[hatena] アーカイブへ移動: $(basename "$f")"
fi
done
--publish フラグ付きで実行した場合のみ、投稿済みファイルを published/ ディレクトリに移動します。Desktop のキューから外すことで、「published/ にある本日分のカウント」が増え、次の daily.sh 実行時に PUB_TODAY が正しくカウントされます。このアーカイブ移動が冪等設計の歯車として機能しています。
daily.sh は post-to-hatena.sh --publish --all として呼び出しているため(36行目)、公開と同時に自動でアーカイブに移動します。
audit-heal.sh ― 自己修復の実装
最後のステップが audit-heal.sh です。3つの役割を順番に実行します。
1. 壊れ記事の掃除(23〜29行目)
for f in "$OUT"/*.md; do
[ -e "$f" ] || continue
if /usr/bin/grep -qE '不明な商品|Request timed out' "$f"; then
log " [掃除] 壊れ記事を削除: $(basename "$f")"
rm -f "$f"
fi
done
resp_is_valid() で弾いて壊れ記事を書かない設計にはなっていますが、過去の実行や手動介入でキューに混入した場合のセーフティネットです。"不明な商品" や "Request timed out" を含むファイルは問答無用で削除します。
2. アフィリリンク含有チェック(36〜44行目)
for f in "$ARCHIVE/${TODAY}"_*.md; do
published=$((published+1))
title="$(sed -n 's/^# //p' "$f" | head -1 | cut -c1-30)"
if /usr/bin/grep -q 'hb.afl.rakuten.co.jp' "$f"; then
link="✓アフィリ"
else
link="✗非アフィリ"; bad_link=$((bad_link+1)); problems=$((problems+1))
fi
log " ${link} | ${title}"
done
公開済みの全記事に hb.afl.rakuten.co.jp が含まれているかチェックします。postprocess_body() が正常に動作していれば全件 ✓アフィリ になりますが、何らかの理由で差替が失敗した場合にここで検出できます。
3. 目標達成チェックとmacOS通知(52〜60行目)
if [ "$published" -lt "$TARGET" ]; then
log " ⚠ 公開が目標未達(生成 or 公開が失敗した可能性)"
problems=$((problems+1))
fi
if [ "$problems" -gt 0 ]; then
log " ❌ 監査NG: 要確認 (${problems}件)"
notify "監査NG: 公開${published}/${TARGET}本・非アフィリ${bad_link}本。logs/audit-${TODAY}.log を確認"
exit 1
else
log " ✅ 監査OK: ${published}本すべてアフィリリンク付きで公開"
exit 0
fi
notify() は osascript -e "display notification..." でmacOSの通知センターに飛ばします。ログファイルは logs/audit-YYYY-MM-DD.log に残るため、問題の発生日時と内容を後から追跡できます。
この監査が exit 1 で終わると、daily.sh 側でも「⚠ 監査NG」をコンソールに出力します(40行目)。launchdの実行ログを見れば、どの実行で何が起きたかが追跡できます。
次回は、この仕組みを実際に立ち上げるときに私がぶつかった失敗(.env 消滅による報酬ゼロ再発・launchd plist破損・自己修復ウォッチドッグが自分でファイルを壊した事故)と、同じ轍を踏まないための設計指針を書きます。
実装の詳細
「除外リスト」で同じ商品を二度書かせない
記事工場が最初に直面するのは、重複コンテンツです。LaunchDが毎日走るということは、毎日「あなたが決めたジャンルで新製品を1本書いて」とClaudeに指示することになります。なにも手を打たないと、ロボット掃除機の記事が30本並んでも全部「Roborock S8 Pro Ultra」という地獄になります。
解決するのが posted-products.log(実際のパスは $AFFILIATE_FACTORY_LOG)と、それをプロンプトに埋め込む仕組みです。generate.sh の8〜19行目がその実装です。
LOG="${AFFILIATE_FACTORY_LOG:-$DIR/posted-products.log}"
# ...
touch "$LOG"
# 既出商品(重複回避用)
EXCL=$(paste -sd '、' "$LOG" 2>/dev/null)
[ -z "$EXCL" ] && EXCL="(まだ無し)"
このEXCL変数がプロンプトの # 除外(これらの製品は今回選ばない) セクションに $EXCL として差し込まれます。1本生成するたびに、ファイル末尾に商品名が1行追記されるので(298行目)、100本溜まれば100製品が除外リストに並びます。
[ "$PRODUCT" != "不明な商品" ] && echo "$PRODUCT" >> "$LOG"
肝は「モデルへの制約はプロンプトで伝える」 という一点です。コードで重複チェックをしようとすると、タイトルの表記ゆれやブランド違いを吸収するための文字列マッチングが必要になり、例外処理が膨らみます。「過去に選んだ商品名一覧を渡して、これを選ばないよう指示する」だけで、Claudeがよしなに避けてくれます。モデルに任せていい仕事は積極的にモデルに投げる、という思想がここに出ています。
.env の自動ロードとスコープ
generate.sh の12行目には1行だけこんな書き方があります。
[ -f "$DIR/.env" ] && set -a && . "$DIR/.env" && set +a
set -a は「以降に定義した変数を自動でexportする」モード、set +a で解除します。.env を source するだけだと、bashの挙動によっては変数がサブプロセスに引き継がれないことがあります。楽天APIの認証情報(RAKUTEN_APPLICATION_ID など)はPython3サブシェルに渡る必要があるため、set -a でexport込みロードしています。
.env が存在しない場合は何も起きません。gitには .env を追加せず .env.example だけコミットするという標準的な構成ですが、「.env がなくてもスクリプトが落ちない」ことが意外と重要です。launchdはシステム起動時にも発火するため、.env がなければ環境変数が空のまま実行されます。その場合は RAKUTEN_APPLICATION_ID が未定義になり、generate.sh の83〜86行目の条件で rakuten_search_url() にフォールバック——つまり「報酬のつかない素リンク」で記事を書き続けます。スクリプトは落ちないが収益もゼロ、という最悪のサイレント失敗です。後述する「私が詰まった話」でこれを実際にやらかしています。
GEN_TIMEOUT の「ケチらない」哲学
generate.sh の260〜263行目のコメントに、私が試行錯誤した跡がそのまま残っています。
# フル記事生成の所要時間。従来(2500字・検索数回)で約260sだったが、本文を8000〜10000字+
# 競合3製品の追加WebSearchに増やしたため生成が伸びる。余裕を持って実測の数倍を確保する。
# 短いと長文の途中でkillされ空応答→0本公開になるため、ここはケチらない。
GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-1200}"
最初は GEN_TIMEOUT=300 で動かしていました。単純な生成ならWebSearch数回込みで4〜5分で終わります。ところが「競合3製品もWebSearchして実在スペックを確認してから書く」という指示を足した途端、平均が12〜15分に伸びました。timeout 300 は300秒でプロセスをkillするので、長文生成の途中でClaudeが強制終了され、RESP が空になります。resp_is_valid() が空を弾いて3回リトライ、全滅して exit 1——これが「今日は1本も生成されなかった」の正体でした。
1200秒(20分)は実測の約1.5倍です。AFFILIATE_FACTORY_GEN_TIMEOUT で上書きできるようにしてあるのは、将来プロンプトを短く変えたときにコードを触らずに調整できるようにするためです。環境変数でデフォルト値を持ち、必要なら外から上書きというパターンは、スクリプト全体で統一しています(AFFILIATE_FACTORY_OUT・AFFILIATE_FACTORY_LOG・AFFILIATE_FACTORY_TARGET も同じ構造です)。
AFFILIATE_FACTORY_TEST_RESPONSE でモックテスト
Claude APIは呼び出すたびに課金されます。ロジックの変更をテストするたびに実際にAPIを叩くのは、コストとしても時間としても非効率です。そのために generate.sh 265〜266行目に差し込んだのが AFFILIATE_FACTORY_TEST_RESPONSE です。
if [ -n "${AFFILIATE_FACTORY_TEST_RESPONSE:-}" ]; then
RESP="$AFFILIATE_FACTORY_TEST_RESPONSE"
else
for attempt in 1 2 3; do
RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" ...)
この環境変数にダミー応答を渡してスクリプトを動かすと、APIを呼ばずに resp_is_valid() → 商品名抽出 → postprocess_body() → ファイル書き出しまでを全部走らせられます。たとえば次のように使います。
export AFFILIATE_FACTORY_TEST_RESPONSE='PRODUCT: テスト掃除機 X100
# 【2025年】テスト掃除機 X100 全スペック解説
> ※本記事はアフィリエイトプログラムを利用しています。
[:contents]
## この記事でわかること
テスト記事です。'
bash generate.sh 1
これで「楽天リンクが正しく差し替わっているか」「ファイル名がタイムスタンプ付きで作られているか」「posted-products.logに商品名が追記されているか」を一気に確認できます。本番環境のコードパスをそのまま通るので、ユニットテストとは違う実際の挙動の確認になります。
はてなMarkdown特有の罠:[:contents] と免責 blockquote の空行
postprocess_body() の最後に、最終的な出力の組み立て順がハードコードされています(generate.sh 240行目)。
out = [title, "", disclaimer, "", contents]
"" が2つあることに気づくでしょうか。タイトルの後ろに空行、disclaimer(免責のblockquote)の後ろにも空行を置いてから [:contents](目次)を入れています。
最初は [title, disclaimer, contents] と詰めて書いていました。このとき、はてなブログ上でなぜか目次が表示されなかったり、目次が免責blockquote内に吸い込まれて見た目が崩れたりする問題が起きました。原因を調べると、はてなのMarkdown処理系はblockquote(> で始まる行)の直後に空行なしで[:contents]が来ると、それを「blockquoteの継続」として処理してしまうことがあるという仕様上の挙動でした。
空行を1行挟むことで、パーサーが「blockquoteがここで終わった」と判断し、[:contents] が独立した目次指令として解釈されます。これははてなMarkdown特有のクセです。普通のMarkdownレンダラーやnote、Zennでは起きません。
post-to-hatena.sh の config ガードと H1 分離
post-to-hatena.sh にはスクリプト上部に1つのガード処理があります(19〜24行目)。
CFG="$HOME/.config/blogsync/config.yaml"
if [ ! -f "$CFG" ] || /usr/bin/grep -q 'REPLACE_' "$CFG"; then
echo "[hatena] スキップ: $CFG が未設定です(はてなID/APIキー未入力)。" >&2
exit 0
fi
blogsync の設定ファイルに REPLACE_ という文字列が残っている(=テンプレのまま未設定)場合、静かに exit 0 して何もしません。daily.sh から呼ばれても「投稿0本」として扱われます。これがないと、設定忘れのままスクリプトが走って blogsync がエラーを吐き、daily.sh 全体が止まる可能性がありました。
もうひとつ重要なのが post_one() 関数内のタイトル分離です(26〜38行目)。
title="$(sed -n 's/^# //p' "$file" | head -1)"
[ -z "$title" ] && title="$(basename "$file" .md)"
body="$(awk 'NR==1 && /^# /{next} {print}' "$file")"
Markdownファイルの1行目の # タイトル を抜き出してblogsyncの --title 引数に渡し、本文からはその1行目を除いて投稿します。これをしないとブログの見出しが「H1タイトル」として記事の中に丸ごと入り、はてなブログの記事タイトルと記事内H1が二重になります。SEO的にも見た目的にも最悪なので、本文からH1を剥がして --title に渡すのが正しい設計です。
私が詰まった話
1.「.env 消滅」で報酬ゼロ再発——2回目のやらかし
最初にこの問題に気づいたのは、1週間ぶりに楽天アフィリエイトの管理画面を開いたときです。記録上は毎日5本公開されているのに、報酬のグラフがまったく動いていませんでした。
audit-heal.sh のログを遡ると、✅ 監査OK が並んでいます。hb.afl.rakuten.co.jp を含有チェックしているはずなのに、なぜOKが出るのか。published/ 配下の記事を直接 grep hb.afl.rakuten.co.jp すると、1件も該当なしでした。
generate.sh を手動で走らせてみると、コンソールに次のログが流れました。
[generate] 商品個別リンクを取得できず検索リンクにフォールバック: Panasonic NA-LX129B
このログは「個別商品が取れなかったからhgcリンクに落とす」ではなく、楽天APIを呼び出す前の、もっと手前の分岐で出ていました。RAKUTEN_APPLICATION_ID が空文字のとき、Pythonコードに入る前の83〜86行目で素の検索URLが返ります。
if [ -z "${RAKUTEN_APPLICATION_ID:-}" ] || [ -z "${RAKUTEN_ACCESS_KEY:-}" ] || [ -z "${RAKUTEN_AFFILIATE_ID:-}" ]; then
rakuten_search_url "$product"
return
fi
rakuten_search_url() は search.rakuten.co.jp(報酬ゼロ)を返します。postprocess_body() はこれをアフィリリンクとして埋め込むため、リンク自体は存在します。しかし hb.afl.rakuten.co.jp ではないので、audit-heal.sh のチェックをすり抜けて「非アフィリ」と検出されないまま公開されるのです。
原因は .env の消滅でした。このシステムとは別の自動化スクリプト(自己修復ウォッチドッグ)が同じディレクトリを対象に動いており、後述する事故で affiliate-factory/.env が上書き消去されていました。gitignoreで管理外なので、git checkoutでも復元できません。
直し方は2段階です。
①監査の検出ロジックを修正する:audit-heal.sh の含有チェックを hb.afl.rakuten.co.jp だけでなく search.rakuten.co.jp を「非アフィリ」として明示的に弾くように変えました。素の検索URLが混入した時点で ✗非アフィリ を立てれば、problems > 0 → macOS通知で即座に気づけます。
②.env を自己修復の射程外に置く:秘密値を含むファイルは絶対にスクリプトが書き換えてはいけません。自己修復ウォッチドッグのスコープを「生成ログとドラフトファイルのみ」に限定し、.env や設定ファイル群を明示的に除外するように修正しました。
2回目のやらかしだったので、さすがに怒りが自分に向きました。秘密値を持つファイルは「自動化の手が届かない聖域」として最初から設計に入れるべきでした。
2. 自己修復ウォッチドッグが本体のファイルを破壊した
これは「怖い話」系の失敗です。
別のプロジェクトで「自己修復ウォッチドッグ」と呼んでいるスクリプトを作っていました。スクリプトが異常終了したり出力がおかしくなったりしたとき、既定のファイルセットを書き直して修復するという仕組みです。
そのウォッチドッグに fd77c12 fix(self-repair) というコミットで修正を入れた直後、affiliate-factory ディレクトリが壊れ始めました。具体的には:
-
.envが0バイトに上書きされた -
post-to-hatena.shが別の内容(前バージョンのコード)に置き換わった - launchd plist(後述)が文法エラーを含む内容に破損した
すべてのファイルの mtime が同じ時刻になっており、「何かが一斉に書き直した」のは明らかでした。
原因は、ウォッチドッグが出力を標準出力に書き出しながら同時にファイルを操作する処理で、シェル変数の展開とリダイレクトの順序がかみ合わず、ターゲットファイルが決まる前にリダイレクト先が開かれて内容が消えるというシェルの典型的な罠にはまっていました。結果として、意図していないパスのファイルが上書きされました。
直し方は、ウォッチドッグの書き込み処理をすべて「一時ファイルに書いてから mv で原子的に差し替える」パターンに変えることでした。
# NG: リダイレクトがファイルを開いた時点でTARGET_FILEが空になり得る
some_command > "$TARGET_FILE"
# OK: tmpに書いてからmvで原子置換
some_command > "$TARGET_FILE.tmp" && mv "$TARGET_FILE.tmp" "$TARGET_FILE"
さらに「自己修復スクリプトが書き換えてよいファイルは何か」を明示的にホワイトリスト化しました。それ以外のファイルには触れないようにガードを入れています。自己修復という「優しい機能」は、スコープ制限がなければ最凶の破壊者になります。
3. launchd plist が壊れて2週間気づかなかった
上記の事故で launchd plist が破損したとき、すぐには気づきませんでした。MacはSleep/Wakeを繰り返しているだけでlaunchdジョブが再登録されないため、plistが壊れていてもログに何も出ません。記事が生えてこない、でも手動で bash daily.sh を叩けば動く——この状態が2週間続きました。
気づいたのは launchctl list | grep affiliate を叩いたとき、ジョブが一覧に出なかったからです。
# ジョブが登録されているか確認
launchctl list | grep affiliate
# → 出力なし(登録されていない)
# plist の文法チェック
plutil ~/Library/LaunchAgents/com.affiliate-factory.daily.plist
# → com.affiliate-factory.daily.plist: Unexpected character < at line 3
# 修復:アンロードしてplistを直してリロード
launchctl unload ~/Library/LaunchAgents/com.affiliate-factory.daily.plist 2>/dev/null || true
# plistを正しい内容に書き直す
launchctl load ~/Library/LaunchAgents/com.affiliate-factory.daily.plist
再発防止として、audit-heal.sh の末尾に次の確認を追加しました。
# launchdジョブが生きているか確認(停止中なら警告だけ出す)
if ! launchctl list 2>/dev/null | grep -q 'affiliate-factory'; then
log " ⚠ launchdジョブが未登録。plistを確認してください"
problems=$((problems+1))
fi
毎朝の監査でジョブ登録状況もチェックすることで、「動いていない状態」を翌朝には必ず検出できるようになりました。
launchd plistについて1点だけ補足します。plistに書くコマンドは絶対パスでなければなりません。/bin/bash は良いですが、bash は不可です。また PATH 環境変数は /usr/bin:/bin 程度しか通っていないため、nvm 経由でインストールしたコマンドや、~/.local/bin/ のバイナリはフルパス指定が必須です。このシステムでは generate.sh 内の CLAUDE 変数(9行目)でclaudeのフルパスを指定しているのはそのためです。
CLAUDE="${CLAUDE:-~/.local/bin/claude}"
パスを環境変数で上書きできる設計にしておくことで、claudeのインストール先が変わったときもコードを触らずに対応できます。
3つの失敗に共通するのは「自動化が自分自身を壊す」という構造です。壊れることを前提に設計してある audit-heal.sh が、壊れたことに気づかないまま壊れていたのが最大の皮肉でした。次のセクションでは、これらの失敗から導いた設計指針と、このシステムを0から立ち上げる最短ルートをまとめます。
前回の中段では .env 消滅・自己修復の暴走・launchd plist 破損という3大失敗を解剖しました。この終段では、それ以外に実運用で踏んだ細かなつまずきを一覧にし、そこから引き出した設計指針をベストプラクティスとして整理します。
つまずきポイント
これまで解説した3大失敗に加えて、コードを実際に動かすと必ず一度はぶつかるポイントをまとめます。箇条書きで並べていますが、どれも「実際にやらかした」ものか「設計上の落とし穴として後から気づいた」ものです。
① TARGET の数値が daily.sh と audit-heal.sh でズレている
daily.sh 10行目は TARGET=5 のハードコード。一方 audit-heal.sh 11行目は TARGET="${AFFILIATE_FACTORY_TARGET:-3}" で、環境変数未設定時のデフォルトが3です。この状態で両スクリプトを動かすと、daily.sh は毎日5本を目指して生成・投稿しますが、audit-heal.sh は3本公開を「OK」と判定します。5本公開されても「⚠ 公開が目標未達」が出ず、3本しか公開できていない日でも監査OKが出るという逆転現象が起きます。daily.sh 側も TARGET="${AFFILIATE_FACTORY_TARGET:-5}" と環境変数経由にすべきでした。.env に AFFILIATE_FACTORY_TARGET=5 と1行書けば全スクリプトが統一されます。
② posted-products.log の肥大化でプロンプトが膨れる
generate.sh 18行目の EXCL=$(paste -sd '、' "$LOG") はログ全件を「、」つなぎにしてプロンプトへ埋め込みます。毎日5本、1年続けると1,825エントリです。商品名1件を平均20文字とすると全体で約3.7万文字。Claude Sonnet のコンテキストには収まりますが、プロンプト全体が5〜6万トークンに達し、1回の生成コストが半年目から段階的に上がり始めます。四半期に一度は古いエントリをアーカイブし、tail -n 200 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG" で直近200件だけ残す月次 cron を入れてください。
③ resp_is_valid が記事本文の "Error:" に引っかかる
generate.sh 255行目の判定は grep -qiE 'request timed out|error:|rate limit|usage limit' です。正常に生成された記事本文に「このエラー(Error: E10)コードが表示されたら充電を確認してください」のような文が含まれると、resp_is_valid が失敗と判定して3回リトライ後に exit 1 します。高級家電のレビューではエラーコードの説明が入ることが多く、ロボット掃除機やドラム式洗濯機のレビューで実際に引っかかりました。grep -qiE '^(request timed out|error:|rate limit)' <(printf '%s\n' "$r" | head -5) のように先頭数行だけを判定対象にするか、語頭アンカーで本文内の自然な "Error:" を除外する改修を推奨します。
④ launchd の多重起動で生成コストが二重になる
StartCalendarInterval に朝5時・昼12時・夜20時の3時刻を設定している場合、朝の実行が20分かかっている最中に昼のインターバルが発火すると2つの daily.sh が並列で走ります。片方が NEED=3 と計算して3本生成を始め、もう片方も同時に NEED=3 と計算して3本走らせます。最終的に PUB_TODAY が6になっても冪等設計が「超過は0本」と吸収するため公開数は問題ありません。しかし Claude API の呼び出しコストは二重に発生します。daily.sh の冒頭に flock -n /tmp/affiliate-factory.lock -c "bash ${0}" のようなロックを入れると多重起動を防げます。
⑤ --model sonnet がハードコードされていてモデルを切り替えられない
generate.sh 272行目は --model sonnet 固定です。コストを抑えたいときに Haiku に切り替えたくても、コードを直接編集してコミットし直す必要があります。GEN_MODEL="${AFFILIATE_FACTORY_MODEL:-sonnet}" として --model "$GEN_MODEL" に変えておけば、.env に1行書くだけで次の実行から切り替わります。ジャンルによって深い調査が必要な日は Opus 4.8 へ一時的に上げ、量産期は sonnet に戻す、といった運用が可能になります。
⑥ RAKUTEN_AFFILIATE_ID だけが空のとき危険な中途半端状態になる
generate.sh 83〜86行目は「3つの環境変数のいずれかが空なら素の検索URLを返す」条件分岐です。3変数すべて設定 or すべて未設定であれば挙動が一貫しますが、RAKUTEN_APPLICATION_ID と RAKUTEN_ACCESS_KEY は設定済みで RAKUTEN_AFFILIATE_ID だけが空だと、Python コードに入ってAPIを叩き、187行目の hgc フォールバックリンクに空の affiliate_id が展開されます(/hgc// という二重スラッシュ)。このリンクは楽天のアフィリ計測にのらず報酬ゼロになります。起動時に「3変数のうち1つでも空なら即 exit 1 して停止」するガードを最初から入れておくべきでした。
⑦ ブランド一致チェックが「カタカナブランド」に弱い
generate.sh 172行目の brand in (result["name"] + " " + result["url"]).lower() は、商品名の先頭語の小文字がAPIレスポンスのどこかに含まれるかで一致を判定します。"Dyson" → URL スラッグが dyson のストア名で一致するので概ね機能します。しかし "Balmuda" のような製品で楽天のストア登録名が「バルミューダ」のカタカナ表記だと、URL スラッグに balmuda が入らず不一致と誤判定します。この場合は個別商品リンクが取れずに hgc フォールバックへ落ちます。報酬は乗りますが、個別商品への直リンクより CVR が低下します。カタカナブランド名をローマ字に変換するテーブルか、ブランドごとの例外リストを持つ対応が必要です。
⑧ 楽天 API エンドポイントのバージョン変更を見逃す
generate.sh 103行目の API = "https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20260401?" は2026年4月更新版のエンドポイントです。旧エンドポイント(app.rakuten.co.jp)を使っているコードは2026年4月以降、403を返します。楽天アフィリエイトのAPIバージョンアップ告知を見落とすと、全件が0件応答になり全記事が hgc フォールバックで出力され続けます。「記事は公開されているが個別商品リンクが1件もない」という状態が数日続いてから気づくパターンです。半年に一度、楽天の開発者ポータルで現行エンドポイントを確認する点検を cron か手作業でスケジュールしてください。
⑨ blogsync のパスが launchd 環境で通らない
post-to-hatena.sh 13行目の BLOGSYNC="${BLOGSYNC:-$HOME/.local/bin/blogsync}" は環境変数未設定なら ~/.local/bin/blogsync を探します。Homebrew でインストールした場合は /opt/homebrew/bin/blogsync に入ります。launchd の PATH は /usr/bin:/bin 程度しか通っていないため、どちらのパスも解決できません。blogsync: command not found が出て投稿が全件失敗しますが、--all ループが続いてスクリプト自体は exit 0 で終わります。ログには「投稿: 0本」と出るだけで、エラーと気づきにくいです。launchd plist の EnvironmentVariables に BLOGSYNC=/opt/homebrew/bin/blogsync をフルパスで書くか、BLOGSYNC 環境変数を .env に明記してください。
⑩ shopt -s nullglob を忘れると「ファイルゼロ」で1件ループが回る
audit-heal.sh 34行目は shopt -s nullglob でグロブが空のときに空配列を返すよう設定してから35行目の for ループに入り、44行目で shopt -u nullglob に戻します。この nullglob を忘れると、published/ に当日ファイルが1件もない状態でもシェルはリテラル文字列 "$ARCHIVE/2026-06-23_*.md" でループを1回走らせ、[ -e "$f" ] が失敗してスキップされます。結果として published=0 のまま「⚠ 公開が目標未達」に正しく到達するのですが、ループが走った痕跡がログに残り混乱を招きます。glob を使う for ループには必ずペアで shopt を設定する習慣を持ってください。
⑪ macOS の「集中モード」で osascript 通知が届かない
audit-heal.sh 16行目の notify() は osascript -e "display notification ..." でmacOS通知を飛ばします。macOS 15以降の集中モード(Focus)が「睡眠」に設定されていると、この通知がバナー表示されず通知センターにのみ蓄積されます。朝7時に起きて確認したつもりが、通知センターを開かないと気づかない状態になっていました。launchd の StandardOutPath / StandardErrorPath を plist に設定してログをファイルに書き出すか、Slack Webhook や LINE Notify への副経路を持つことを推奨します。
⑫ macOS のスリープ中に StartCalendarInterval がスキップされる
launchd の StartCalendarInterval は macOS がスリープ中のとき、スケジュール時刻を通過してもジョブが発火しません。起床後のログイン時に missed な実行が発火するケースもありますが、タイミング次第です。「朝5時に起動するはずが10時まで実行されなかった」日が月に数回あります。冪等設計で昼の実行が補完するため記事は1日3〜5本に収束しますが、タイミングがずれます。MacBook をクラムシェルモード(蓋を閉めてディスプレイ接続)で常時起動するか、Mac mini・VPS など常時起動環境に移行するのが根本解決です。
ベストプラクティス
1年間の運用と複数回の事故から引き出した設計指針です。新たにシステムを組む方は最初からこの指針を意識して設計してください。
1. 秘密値ファイルは「自動化の射程外」として最初から聖域化する
.env や ~/.config/blogsync/config.yaml は自動化スクリプトが書き換えてはいけません。自己修復・ウォッチドッグ・バックアップスクリプトが書き換えてよいファイルをホワイトリストで明示し、それ以外には触れない設計を最初に入れてください。「動いていたのに突然報酬がゼロになる」の原因は、ほぼ必ず秘密値の消失です。gitignore で管理外にしている秘密値は、スクリプトの事故で消えると git checkout でも戻せません。
2. TARGET は環境変数 1つで全スクリプトが共有する
.env に AFFILIATE_FACTORY_TARGET=5 と書き、全スクリプトが TARGET="${AFFILIATE_FACTORY_TARGET:-5}" で読む設計に統一してください。daily.sh のハードコード TARGET=5 と audit-heal.sh のデフォルト 3 のようなズレは、監査の誤判定を生み続けます。数値の変更が .env 1行の修正で全体に反映されるのが正しい状態です。
3. アフィリリンクの存在を毎朝 grep で確認し、サイレント報酬ゼロを根絶する
audit-heal.sh の grep -q 'hb.afl.rakuten.co.jp' "$f" に加えて、grep -q 'search.rakuten.co.jp' "$f" で素URLを「非アフィリ」として明示検出するロジックも入れてください。hb.afl の不在だけを検出すると、.env 消滅時に search.rakuten.co.jp(報酬ゼロ)が混入してもOKと判定されます。素URLが混入した瞬間に macOS 通知が届く設計にすれば、1日以内に気づけます。
4. GEN_TIMEOUT は実測値の1.5倍以上に設定する。ここはケチらない
8,000〜10,000字+競合3製品WebSearchの平均生成時間は12〜15分です。GEN_TIMEOUT=1200(20分)はこの1.5倍の余裕込みの設定です。タイムアウトを短くすると「生成途中でkill→空応答→3回失敗→0本」という連鎖が確定します。生成コストより公開0本の損失のほうが確実に大きいです。新しいプロンプト設計に変えたときは手動で数回実行して所要時間を実測し直してください。
5. 出力フォーマット強制(PRODUCT: 行)を外さない
resp_is_valid() と商品名抽出の両方が ^PRODUCT: 行に依存しています。プロンプトを改変するときにこの1行の出力強制を外すと、パース処理が全部崩れます。出力フォーマットの変更はスクリプト全体への影響確認が必須です。変更する場合は次に挙げる AFFILIATE_FACTORY_TEST_RESPONSE でドライランしてから本番に入れてください。
6. AFFILIATE_FACTORY_TEST_RESPONSE でドライランを必ず入れる
generate.sh 265〜266行目のモックレスポンス機構です。プロンプト変更・postprocess_body 改修・リンク差替ロジック追加のたびに、APIゼロコストでフルパスを通せます。```bash
export AFFILIATE_FACTORY_TEST_RESPONSE='PRODUCT: テスト掃除機 X100
【2026年】テスト掃除機 X100 全スペック解説|競合3機種比較
※本記事はアフィリエイトプログラム(楽天アフィリエイト等)を利用しています。
[:contents]
この記事でわかること'
bash generate.sh 1
上記コマンドで楽天リンクの差替・ファイル出力・`posted-products.log` への追記まで全パスが通ります。本番 launchd で初めて動かして「翌朝0本」が確定するミスを防げます。
**7. 自己修復スクリプトのスコープはホワイトリスト制で厳格に限定し、書き込みは原子置換のみ**
自己修復が書き換えてよいファイルを明示的に列挙してください。書き込み処理はすべて「一時ファイル → `mv` で原子置換」パターン一択です。`some_command > "$TARGET_FILE"` は絶対に使いません。`some_command > "$TARGET_FILE.tmp" && mv "$TARGET_FILE.tmp" "$TARGET_FILE"` にします。シェル変数の展開とリダイレクトの順序がかみ合わないと、意図しないパスのファイルが上書きされます。これが実際に `.env` と `post-to-hatena.sh` を一斉に破壊した原因でした。
**8. launchd ジョブの死活確認を監査に組み込む**
```bash
# audit-heal.sh 末尾に追加
if ! launchctl list 2>/dev/null | grep -q 'affiliate-factory'; then
log " ⚠ launchdジョブが未登録。plistを確認してください"
problems=$((problems+1))
fi
このチェックを audit-heal.sh に追加すると、plist が破損してジョブが未登録になった翌朝に必ず検出できます。2週間気づかなかった失敗の再発を防ぐ最低限のガードです。
9. launchd plist は plutil で構文チェックし、コミット管理する
plutil ~/Library/LaunchAgents/com.affiliate-factory.daily.plist
# → com.affiliate-factory.daily.plist: OK
plist は XML です。閉じタグの抜け・<key> 前後の余分な文字で壊れます。plutil は構文エラーを行番号付きで出力します。コード変更のたびに plutil を通してから launchctl unload && launchctl load でリロードする手順を固定してください。plist は .gitignore 対象から外してバージョン管理します。破損時に git から復元できるのとできないのとでは、発覚からの復旧時間が1時間変わります。
10. blogsync のパスは BLOGSYNC 環境変数でフルパス指定し、launchd plist にも記載する
launchd の EnvironmentVariables に BLOGSYNC=/opt/homebrew/bin/blogsync(または ~/.local/bin/blogsync)を明記してください。which blogsync の出力をそのまま plist に書きます。PATH の通り方に依存すると、macOS のバージョンアップや Homebrew のプレフィックス変更(Intel→Apple Silicon移行)で突然 command not found になります。
11. --model を環境変数化して再デプロイ不要でモデル切り替えを可能にする
generate.sh 272行目の --model sonnet を --model "${AFFILIATE_FACTORY_MODEL:-sonnet}" に変えてください。コストを削りたいときは .env に AFFILIATE_FACTORY_MODEL=claude-haiku-4-5-20251001 と書くだけで次の実行から切り替わります。特定ジャンル(高単価・技術系)の記事だけ一時的に Opus 4.8 へ上げる運用も、コードを触らずに実現できます。
12. posted-products.log は月次でローテーションしてプロンプトコストを管理する
# 月次 cron に追加
tail -n 200 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
直近200件だけを残せば除外効果は十分で、トークンコストを 1/9 以下に削減できます。月商で稼ぐためにトークンコストを上げていくのは本末転倒です。ログのローテーションは収益化システムのコスト管理の一部です。
13. 通知だけに頼らず logs/audit-YYYY-MM-DD.log を週1回確認する
audit-heal.sh の AUDIT_LOG="$DIR/logs/audit-${TODAY}.log" に毎朝の監査結果が全件残ります。macOS 通知が集中モードでスキップされた日でも、ログを見れば何本公開できたかがわかります。週に一度 grep '✅\|❌' logs/audit-*.log | tail -14 を実行して直近2週間の OK/NG 率を確認してください。連続してNGが出ている日があれば、その日のログで原因を追跡できます。
まとめ
前回の第1回でシステムの骨格(launchd→daily.sh→generate.sh→post-to-hatena.sh→audit-heal.sh)を解説し、今回は「収益を確実に刻む後半の仕組み」を一通り公開しました。
このシステムを設計面と失敗面の2軸で評価するとこうなります。
設計面の核心は「冪等性」と「自己修復」の二本柱です。 何度実行しても1日 TARGET 本に収束する冪等設計があるから、朝の失敗は昼が自動で補います。audit-heal.sh が毎朝走って異常を検出し、macOS 通知で人間に伝えるから、問題に気づくまでのタイムラグが1日以内に収まります。私が確認に使う時間は毎朝3分以下です。
失敗面の教訓は「自動化が自分自身を壊す」リスクを最初から設計に組み込むことです。 .env 消失・自己修復の暴走・launchd plist 破損——これらはすべて「動いているはずの自動化」が水面下で静かに壊れていた例でした。ログを見なければ気づかない状態が続き、気づいたときには報酬がゼロになっていたり、2週間分の投稿機会が失われていたりします。壊れることを前提に設計し、壊れたことを翌朝には必ず検出する仕組みを持つこと——それが長期運用の命です。
月商120万の大部分は私が寝ている間に積み上がっています。この仕組みはその中核のひとつであり、「環境を建てる」という半年間の投資の結果です。記事を書く時間をゼロにするのではなく、自分が価値を生めない時間帯に機械が自動で動き続ける状態を建てることが、時間を売る副業との決定的な違いです。
次回は、このアフィリ工場を含む Claude Code 自律環境全体の設計——複数の工場を並列で走らせるときのリソース管理・モデルルーティング・月単位のコスト管理——について書きます。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!