cd /news/artificial-intelligence/two-weeks-of-silent-failure-idempote… · home topics artificial-intelligence article
[ARTICLE · art-97657] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=↑ positive

Two Weeks of Silent Failure: Idempotent Scheduling and Self-Healing for a launchd + Claude Code Pipeline

A developer has built an autonomous affiliate article generation pipeline using Claude Code and launchd that publishes up to five articles daily to Hatena Blog, with an idempotent scheduling design that self-heals from failures. The system, which generates revenue while the developer sleeps, uses a daily.sh script that runs at morning, noon, and night, converging on the target article count regardless of partial failures. The developer reports that this autonomous environment contributes to a monthly revenue of 1.2 million yen, with the system designed to break if humans intervene.

read36 min views2 publishedAug 15, 2026

Every morning at 5, launchd wakes up, and by the time I do, articles I never wrote are already live.

In Part 1, I covered the basic design of a Claude Code–centered article generator and the skeleton of how generate.sh

produces a single draft. This time I'm publishing the entire back half — the part that actually keeps the system running: how to reliably embed monetization links, how to bail out when the Rakuten API fails, an idempotent design that converges on exactly 5 articles a day no matter how many times you run it, and the self-healing loop built on audit-heal.sh

.

Back when I was a university student making 100,000 yen a month writing content as a side gig, my income equation was simple: hours × rate. The reason I got it up to 600,000 yen a month was that I stacked jobs and pushed my working hours to the limit — not because I had built a system.

When I was laid off, my income went to zero instantly. A side business that sells time collapses the moment there's no one to sell it to. That's when it hit me: building an environment that earns and doing the work that earns are two completely different activities.

After six months of assembling autonomous environments with Claude Code, most of my current 1.2M yen monthly revenue accumulates during hours when I'm not working. This affiliate factory is one of its core pieces.

The biggest bottleneck in earning from affiliate articles is the act of writing. An 8,000–10,000 character review takes at least 3–4 hours including research. Keeping that up 3–5 times a day is physically impossible unless it's your full-time job.

Most writers optimize in the direction of "how do I write faster?" Build templates, hand research to AI, use voice input. All effective, and all with a visible ceiling — because there are only so many hours in a day.

Change the approach at the root and the question changes. Not "how do I write faster?" but "how do I stop having to write at all?" This system is my head-on answer to that question.

In one sentence: launchd hits daily.sh in the morning, at noon, and at night, and generates and publishes only as many articles as today is still short.

The point isn't that I don't have to do anything. More precisely, it's that the design is such that the system breaks if I get involved. Manually adding or deleting files breaks idempotency. Trust launchd's schedule to run on its own, and keep human hands off. That resignation is what makes the system stable.

At 5 a.m. launchd hits daily.sh

. Articles get generated and posted to Hatena Blog, an audit log is written, and if something's wrong a warning lands in the macOS Notification Center. I wake up at 7 and just check that notification. That check takes under three minutes.

Idempotent means "running it any number of times produces the same result." It's spelled out right in daily.sh

's comments.

That's not just a comment — it's the core of the design. Even if the morning run hits a Claude Code rate limit and generates zero articles, the noon run tops up the shortfall. If noon fails too, night covers it. Whenever and however many times it runs, by the end of the day it converges on the target count.

Without this design, every failed morning run would end with "today was a bust." With it, partial failures get absorbed by the system automatically.

The heart of calling Claude inside generate.sh

is this one line (line 272 of the actual script).

RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" --allowedTools WebSearch \
  --model sonnet --permission-mode auto </dev/null 2>/dev/null)

</dev/null

closes stdin, --permission-mode auto

bypasses the permission prompt for WebSearch, and timeout "$GEN_TIMEOUT"

force-kills a hang. The comment reads: "</dev/null

required: without it, claude -p

waits 3 seconds on stdin before proceeding (happens every time under launchd, where there's no tty)." A gotcha specific to the launchd environment.

GEN_TIMEOUT

can be overridden by an environment variable, but the default is 1200

seconds (20 minutes). Since it generates an 8,000–10,000 character article plus WebSearch on three competing products, without that much headroom the process gets killed mid-article, yielding an empty response and the worst outcome: zero articles published. It looks expensive, but compared to the per-article revenue it's nothing.

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

The heart of daily.sh

is a NEED calculation of fewer than 10 lines. Here's the actual code (lines 16–23).

PUB_TODAY=$(find "$ARCHIVE" -maxdepth 1 -name "${TODAY}_*.md" 2>/dev/null | wc -l | tr -d ' ')
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

is hardcoded as TARGET=5

at the top of the script (in audit-heal.sh

it's ${AFFILIATE_FACTORY_TARGET:-3}

— a design where the default is 3 and it can be changed externally via an environment variable).

What matters is that "remaining drafts" is included in the NEED calculation. If an article was generated in a previous run but failed to post and is still sitting on the Desktop, the next run doesn't generate more — it only retries posting. That separates "generation cost (Claude API consumption)" from "posting retries."

The generation loop in generate.sh

(lines 269–276) allows up to three retries per article.

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

The judgment logic in resp_is_valid()

(lines 251–257) is equally concrete.

resp_is_valid() {
  local r="$1"
  [ -z "$r" ] && return 1
  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
}

If all three attempts fail, generate.sh

exits with exit 1

without writing a broken article (lines 280–282). Writing out a file in a broken state pollutes the queue and creates cleanup work for audit-heal.sh

downstream. This check heads off that cost.

The prompt strictly mandates the first-line output format for the product name: PRODUCT: <正式商品名>

. That single line is what makes product-name extraction (line 285) and body extraction (lines 287–289) reliable. Let the model produce ambiguous output and every downstream parse falls apart, so enforcing the output format is mandatory.

The biggest gotcha with the Rakuten API is the 400 Bad Request: "keyword is not valid"

error. Standalone tokens inside a product name — the "Z" or "Ultra" in "Narwal Freo Z Ultra" — get rejected by Rakuten's search engine.

The fix is the "trim one trailing word at a time and retry" strategy in the resolve()

function (lines 152–179).

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

The flow is "Narwal Freo Z Ultra" → "Narwal Freo Z" (400) → "Narwal Freo" (hit). But trim too far and "Narwal" alone might pull in some entirely different, highly-reviewed product. So it verifies whether the brand name (the first word) appears in the returned product name or URL via brand in (result["name"] + " " + result["url"]).lower()

, and if it doesn't match, it decides "this turned into a different product" and stops trimming further.

429 (rate limit) is retried up to twice with time.sleep(1.5)

in between (inside fetch()

, lines 130–148).

When an individual product link simply can't be obtained, resolve()

returns None

. What comes next (lines 182–190) is the essential fallback.

affiliate_url = resolve()
if not affiliate_url:
    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/

is Rakuten Affiliate's search link with affiliate tracking. It points not to an individual product but to "the results page for searching Rakuten Ichiba for this product name" — but the commission still applies.

If any of the environment variables RAKUTEN_APPLICATION_ID

/ RAKUTEN_ACCESS_KEY

/ RAKUTEN_AFFILIATE_ID

is empty, it skips the API call entirely and falls back to a bare search URL (https://search.rakuten.co.jp/search/mall/…

) (lines 83–87). In that state, commissions are zero. Running in production without noticing a misconfigured .env

is the classic cause of zero affiliate revenue.

Article bodies generated by Claude Code sometimes contain bare Rakuten search URLs. Even when the prompt instructs it to insert affiliate links, the model sometimes writes non-affiliate URLs. Publishing that as-is means zero commission.

postprocess_body()

(lines 201–246) cures this in post-processing.

rakuten_md_link_re = re.compile(r"\[楽天で「[^」]*」を探す\]\([^)]*\)")
body = rakuten_md_link_re.sub(lambda m: link, body)
rakuten_any_re = re.compile(r"\[[^\]]+\]\((?:https?:)?//[^)]*rakuten\.co\.jp[^)]*\)")
body = rakuten_any_re.sub(lambda m: link, body)

There's also placeholder handling (lines 219–224). Claude sometimes writes placeholders like (▼楽天で「〇〇」を検索してリンクを貼る)

, and those get detected and replaced by regex too.

With this three-stage replacement logic, no matter how the link was written, it ultimately converges on the correct affiliate URL. That's what "reliably embedding the monetization link" actually consists of.

The --all

mode of post-to-hatena.sh

uses posted-hatena.log

for skip detection (lines 40–61). It records the full path of every posted file in the log, so even if the same file is still around on the next run, it won't be double-posted.

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

Only when run with the --publish

flag does it move posted files into the published/

directory. Taking them off the Desktop queue increases "today's count in published/

", so PUB_TODAY

is counted correctly on the next daily.sh

run. This archive move is a gear in the idempotent design.

Because daily.sh

invokes it as post-to-hatena.sh --publish --all

(line 36), publishing and archiving happen together automatically.

The final step is audit-heal.sh

. It performs three roles in order.

1. Cleaning up broken articles (lines 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

The design already avoids writing broken articles by rejecting them in resp_is_valid()

, but this is a safety net for cases where past runs or manual intervention slipped something into the queue. Files containing "不明な商品" or "Request timed out" are deleted without question.

2. Checking for affiliate links (lines 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

It checks whether every published article contains hb.afl.rakuten.co.jp

. If postprocess_body()

is working correctly, all of them come back ✓アフィリ

; if the replacement failed for some reason, this is where it gets caught.

3. Target check and macOS notification (lines 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()

pushes to the macOS Notification Center via osascript -e "display notification..."

. Log files remain at logs/audit-YYYY-MM-DD.log

, so the time and content of any problem can be traced after the fact.

When this audit ends with exit 1

, daily.sh

also prints "⚠ 監査NG" to the console (line 40). Looking at launchd's execution logs tells you what happened on which run.

Next time I'll write about the failures I ran into while actually standing this thing up (a recurrence of zero commissions caused by a vanished .env

, a corrupted launchd plist, and the self-healing watchdog destroying files on its own), plus design guidelines for not repeating them.

The first thing an article factory runs into is duplicate content. launchd running daily means telling Claude, every day, "write one article about a new product in the genre you chose." Do nothing about it and you get the hell of 30 robot vacuum articles all about "Roborock S8 Pro Ultra."

The fix is posted-products.log

(the actual path is $AFFILIATE_FACTORY_LOG

) plus the mechanism that embeds it into the prompt. Lines 8–19 of generate.sh

are that implementation.

LOG="${AFFILIATE_FACTORY_LOG:-$DIR/posted-products.log}"
touch "$LOG"

EXCL=$(paste -sd '、' "$LOG" 2>/dev/null)
[ -z "$EXCL" ] && EXCL="(まだ無し)"

This EXCL

variable is injected as $EXCL

into the prompt's # 除外(これらの製品は今回選ばない)

section. Every time an article is generated, one product name is appended to the end of the file (line 298), so once 100 articles have accumulated, 100 products line up in the exclusion list.

[ "$PRODUCT" != "不明な商品" ] && echo "$PRODUCT" >> "$LOG"

The key is this single point: constraints on the model are communicated via the prompt. Trying to do duplicate checking in code requires string matching to absorb title variations and brand differences, and the exception handling balloons. Just "hand it the list of previously chosen product names and tell it not to pick those," and Claude avoids them sensibly. The philosophy on display here: aggressively hand the model the work the model can handle.

Line 12 of generate.sh

is written like this, in one line.

[ -f "$DIR/.env" ] && set -a && . "$DIR/.env" && set +a

set -a

is a mode that auto-exports every variable defined afterward; set +a

turns it off. Simply source

-ing .env

can, depending on bash behavior, fail to pass variables down to subprocesses. Since the Rakuten API credentials (RAKUTEN_APPLICATION_ID

, etc.) need to reach a Python3 subshell, they're loaded with export enabled via set -a

.

If .env

doesn't exist, nothing happens. It's the standard setup — don't add .env

to git, commit only .env.example

— but "the script doesn't die when .env is missing" is surprisingly important. launchd also fires at system startup, so if

.env

is absent, it runs with empty environment variables. In that case RAKUTEN_APPLICATION_ID

is undefined, and the condition at lines 83–86 of generate.sh

falls back to rakuten_search_url()

— meaning it keeps writing articles with "bare links that earn nothing." The script doesn't die, but revenue is zero: the worst kind of silent failure. I actually did exactly this, as described in "Where I got stuck" below.The comment at lines 260–263 of generate.sh

preserves the traces of my trial and error.

GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-1200}"

I started out running with GEN_TIMEOUT=300

. Simple generation finishes in 4–5 minutes including a few WebSearches. But the moment I added the instruction "also WebSearch three competing products and confirm real specs before writing," the average stretched to 12–15 minutes. timeout 300

kills the process at 300 seconds, so Claude got force-terminated mid-article and RESP

came back empty. resp_is_valid()

rejects the empty response, three retries, all fail, exit 1

— that was the true identity of "not a single article was generated today."

1200

seconds (20 minutes) is about 1.5× the measured time. It's overridable via AFFILIATE_FACTORY_GEN_TIMEOUT

so that if I shorten the prompt in the future I can adjust without touching code. "Default value in an environment variable, overridable from outside if needed" is a pattern applied consistently across the whole script (AFFILIATE_FACTORY_OUT

, AFFILIATE_FACTORY_LOG

, and AFFILIATE_FACTORY_TARGET

have the same structure).

The Claude API bills on every call. Hitting the real API every time you test a logic change is inefficient in both cost and time. That's why AFFILIATE_FACTORY_TEST_RESPONSE

is wired in at lines 265–266 of generate.sh

.

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" ...)

Pass a dummy response in this environment variable and run the script, and you can exercise everything from resp_is_valid()

→ product-name extraction → postprocess_body()

→ file write, without calling the API. For example:

export AFFILIATE_FACTORY_TEST_RESPONSE='PRODUCT: テスト掃除機 X100

> ※本記事はアフィリエイトプログラムを利用しています。

[:contents]

## この記事でわかること
テスト記事です。'

bash generate.sh 1

This lets you verify in one shot whether the Rakuten link was replaced correctly, whether the file was created with a timestamp in its name, and whether the product name was appended to posted-products.log

. It goes through the exact production code path, so unlike unit tests, it's a check of actual behavior.

[:contents]

and blank lines after the disclaimer blockquote At the end of postprocess_body()

, the final output assembly order is hardcoded (generate.sh

line 240).

out = [title, "", disclaimer, "", contents]

Notice the two ""

entries. There's a blank line after the title, and another after disclaimer

(the disclaimer blockquote), before [:contents]

(the table of contents).

At first I wrote it packed together as [title, disclaimer, contents]

. With that, the table of contents mysteriously failed to display on Hatena Blog, or got sucked into the disclaimer blockquote and wrecked the layout. Investigating, it turned out Hatena's Markdown processor sometimes treats [:contents]

as a continuation of the blockquote when it comes immediately after a blockquote line (a line starting with >

) without a blank line — a quirk of its spec.

Inserting one blank line lets the parser decide "the blockquote ended here," and [:contents]

gets interpreted as a standalone table-of-contents directive. This is specific to Hatena Markdown. It doesn't happen with ordinary Markdown renderers, note, or Zenn.

post-to-hatena.sh

has one guard near the top of the script (lines 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

If the string REPLACE_

is still present in blogsync

's config file (i.e., the template was never filled in), it quietly exit 0

s and does nothing. Called from daily.sh

, it's treated as "0 posts." Without this, the script would run with the config forgotten, blogsync

would throw an error, and all of daily.sh

could halt.

The other important piece is the title separation inside post_one()

(lines 26–38).

title="$(sed -n 's/^# //p' "$file" | head -1)"
[ -z "$title" ] && title="$(basename "$file" .md)"
body="$(awk 'NR==1 && /^# /{next} {print}' "$file")"

It extracts the # タイトル

on the first line of the Markdown file, passes it to blogsync's --title

argument, and posts the body with that first line removed. Without this, the heading goes into the article body as an "H1 title," duplicating the Hatena Blog article title and the in-article H1. That's terrible both for SEO and visually, so stripping the H1 out of the body and passing it to --title

is the correct design.

.env

— my second time I first noticed the problem when I opened the Rakuten Affiliate dashboard after a week away. The records showed 5 articles published every day, yet the commission graph hadn't moved at all.

Going back through audit-heal.sh

's logs, they were lined with ✅ 監査OK

. It's supposed to be checking for hb.afl.rakuten.co.jp

, so why the OKs? Running grep hb.afl.rakuten.co.jp

directly on the articles under published/

returned zero matches.

Running generate.sh

manually, this log scrolled across the console:

[generate] 商品個別リンクを取得できず検索リンクにフォールバック: Panasonic NA-LX129B

That log wasn't coming from "we couldn't get the individual product, so fall back to an hgc link" — it was coming from a much earlier branch, before the Rakuten API was even called. When RAKUTEN_APPLICATION_ID

is an empty string, lines 83–86 return a bare search URL before entering the Python code.

if [ -z "${RAKUTEN_APPLICATION_ID:-}" ] || [ -z "${RAKUTEN_ACCESS_KEY:-}" ] || [ -z "${RAKUTEN_AFFILIATE_ID:-}" ]; then
  rakuten_search_url "$product"
  return
fi

rakuten_search_url()

returns search.rakuten.co.jp

(zero commission). postprocess_body()

embeds that as the affiliate link, so a link does exist. But since it isn't hb.afl.rakuten.co.jp

, it slips right past audit-heal.sh's check and gets published without ever being flagged as non-affiliate.

The cause was a vanished .env

. A separate automation script (a self-healing watchdog) was operating on the same directory, and in the incident described below it had overwritten and wiped affiliate-factory/.env

. Since it's gitignored, git checkout

can't restore it either.

The fix had two parts.

① Fix the audit's detection logic: I changed audit-heal.sh

's content check so that it not only looks for hb.afl.rakuten.co.jp

but also explicitly flags search.rakuten.co.jp

as "non-affiliate." If a bare search URL raises ✗非アフィリ

the moment it appears, problems > 0

→ macOS notification, and I notice immediately.

② Put .env out of self-healing's reach: scripts must never rewrite files containing secrets. I narrowed the self-healing watchdog's scope to "generation logs and draft files only," and explicitly excluded

.env

and the config files.It was my second time doing this, so at that point the anger was directed at myself. Files holding secrets should have been designed in from the start as "sanctuaries automation cannot reach."

This one is a horror story.

In another project I had a script I called the "self-healing watchdog." When a script terminates abnormally or its output goes wrong, it rewrites a predetermined set of files to repair them.

Right after I patched that watchdog in commit fd77c12 fix(self-repair)

, the affiliate-factory

directory started breaking. Specifically:

.env

was overwritten to 0 bytespost-to-hatena.sh

was replaced with different content (a previous version of the code)Every file had the same mtime

, so it was obvious that "something rewrote them all at once."

The cause was a classic shell trap: in a routine where the watchdog wrote output to stdout while simultaneously manipulating files, the order of shell variable expansion and redirection didn't line up, so the redirect target was opened before the target file was determined, wiping the contents. As a result, files at unintended paths got overwritten.

The fix was to change all of the watchdog's write operations to the "write to a temp file, then swap atomically with mv

" pattern.

some_command > "$TARGET_FILE"

some_command > "$TARGET_FILE.tmp" && mv "$TARGET_FILE.tmp" "$TARGET_FILE"

I also made "which files the self-healing script is allowed to rewrite" an explicit whitelist, with guards so it doesn't touch anything else. Self-healing — that "kind" feature — becomes the most vicious destroyer there is without scope limits.

When the incident above corrupted the launchd plist, I didn't notice right away. Since the Mac was just cycling through sleep/wake and the launchd job never gets re-registered, nothing appears in the logs even when the plist is broken. Articles stopped appearing, but running bash daily.sh

manually worked — and that state continued for two weeks.

I noticed when I ran launchctl list | grep affiliate

and the job wasn't in the list.

launchctl list | grep affiliate

plutil ~/Library/LaunchAgents/com.affiliate-factory.daily.plist

launchctl unload ~/Library/LaunchAgents/com.affiliate-factory.daily.plist 2>/dev/null || true
launchctl load ~/Library/LaunchAgents/com.affiliate-factory.daily.plist

To prevent recurrence, I added the following check at the end of audit-heal.sh

.

if ! launchctl list 2>/dev/null | grep -q 'affiliate-factory'; then
  log "  ⚠ launchdジョブが未登録。plistを確認してください"
  problems=$((problems+1))
fi

By checking job registration in the morning audit too, a "not running" state is now guaranteed to be detected by the next morning.

One footnote on launchd plists: commands written in a plist must be absolute paths. /bin/bash

is fine; bash

is not. Also, the PATH

environment variable only has about /usr/bin:/bin

in it, so commands installed via nvm

or binaries in ~/.local/bin/

require full path specification. That's why this system specifies claude's full path in the CLAUDE

variable in generate.sh

(line 9).

CLAUDE="${CLAUDE:-~/.local/bin/claude}"

Designing the path to be overridable by an environment variable means you can adapt without touching code if claude's install location changes.

What all three failures share is the structure of "automation breaking itself." The greatest irony was that audit-heal.sh

, which was designed on the assumption that things break, was itself broken without noticing that it was broken. In the next section I'll pull together the design guidelines derived from these failures and the shortest route to standing this system up from scratch.

In the middle section of the previous part, I dissected the three big failures — the vanished .env

, the runaway self-healing, and the corrupted launchd plist. In this final section, I'll list the smaller stumbling blocks I hit in actual operation and organize the design guidelines drawn from them into best practices.

In addition to the three big failures above, here are the points you're guaranteed to hit at least once when you actually run this code. They're listed as bullets, but every one is either something I actually did or a design pitfall I noticed later.

① The TARGET value differs between daily.sh and audit-heal.sh

daily.sh

line 10 hardcodes TARGET=5

. Meanwhile audit-heal.sh

line 11 is TARGET="${AFFILIATE_FACTORY_TARGET:-3}"

, defaulting to 3 when the environment variable is unset. Run both scripts in this state and daily.sh aims to generate and post 5 per day, while audit-heal.sh judges 3 published articles as "OK." You get an inversion where publishing 5 doesn't trigger "⚠ 公開が目標未達," and a day where only 3 got published still passes the audit. daily.sh should also have gone through an environment variable as TARGET="${AFFILIATE_FACTORY_TARGET:-5}"

. Write one line of AFFILIATE_FACTORY_TARGET=5

in .env

and all scripts are unified.

② posted-products.log bloating inflates the prompt

EXCL=$(paste -sd '、' "$LOG")

at line 18 of generate.sh

joins every log entry with "、" and embeds it in the prompt. Five per day for a year is 1,825 entries. At an average of 20 characters per product name, that's about 37,000 characters total. It fits in Claude Sonnet's context, but the whole prompt reaches 50,000–60,000 tokens and the cost per generation starts climbing in stages from around month six. Archive old entries once a quarter, and add a monthly cron that keeps only the most recent 200 with tail -n 200 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"

.

③ resp_is_valid trips on "Error:" inside the article body

The check at line 255 of generate.sh

is grep -qiE 'request timed out|error:|rate limit|usage limit'

. If a correctly generated article body contains a sentence like "if this error (Error: E10) code appears, check the charge," resp_is_valid

judges it a failure and exit 1

s after three retries. Reviews of premium home appliances often include explanations of error codes, and I actually hit this with robot vacuum and drum washer reviews. I recommend restricting the check to just the first few lines, as in grep -qiE '^(request timed out|error:|rate limit)' <(printf '%s\n' "$r" | head -5)

, or anchoring at the start of the word to exclude natural occurrences of "Error:" in the body.

④ launchd overlapping runs double your generation cost

If StartCalendarInterval

is configured with three times — 5 a.m., noon, and 8 p.m. — and the morning run is 20 minutes in when the noon interval fires, two daily.sh

processes run in parallel. One computes NEED=3

and starts generating 3, and the other computes NEED=3

at the same time and runs 3 as well. Even if PUB_TODAY

ends up at 6, the idempotent design absorbs it as "excess = 0 more," so the published count is fine. But the Claude API call cost is incurred twice. Adding a lock like flock -n /tmp/affiliate-factory.lock -c "bash ${0}"

at the top of daily.sh

prevents overlapping runs.

⑤ --model sonnet is hardcoded, so you can't switch models

Line 272 of generate.sh

is fixed at --model sonnet

. Even when you want to switch to Haiku to cut costs, you have to edit the code directly and commit again. Change it to GEN_MODEL="${AFFILIATE_FACTORY_MODEL:-sonnet}"

and --model "$GEN_MODEL"

, and one line in .env

switches it from the next run. That enables workflows like temporarily bumping up to Opus 4.8 on days when a genre needs deep research, then going back to sonnet during high-volume periods.

⑥ A dangerous half-configured state when only RAKUTEN_AFFILIATE_ID is empty

Lines 83–86 of generate.sh

are a branch that returns a bare search URL "if any of the three environment variables is empty." Behavior is consistent if all three are set or all three are unset, but if RAKUTEN_APPLICATION_ID

and RAKUTEN_ACCESS_KEY

are set and only RAKUTEN_AFFILIATE_ID

is empty, it enters the Python code, hits the API, and expands an empty affiliate_id

into the hgc fallback link at line 187 (producing a double slash, /hgc//

). That link doesn't register with Rakuten's affiliate tracking, so commissions are zero. I should have included a guard from the beginning that stops with exit 1

at startup if even one of the three variables is empty.

⑦ The brand-match check is weak against katakana brands

brand in (result["name"] + " " + result["url"]).lower()

at line 172 of generate.sh

judges a match by whether the lowercased first word of the product name appears somewhere in the API response. "Dyson" → the URL slug matches a store name containing dyson

, so it mostly works. But for a product like "Balmuda," if the Rakuten store's registered name is the katakana 「バルミューダ」, the URL slug won't contain balmuda

and it's misjudged as a mismatch. In that case, no individual product link is obtained and it falls through to the hgc fallback. The commission still applies, but CVR is lower than with a direct link to the individual product. You need a table converting katakana brand names to romaji, or a per-brand exception list.

⑧ Missing a version change in the Rakuten API endpoint

API = "https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20260401?"

at line 103 of generate.sh

is the April 2026 updated endpoint. Code using the old endpoint (app.rakuten.co.jp

) returns 403 from April 2026 onward. Miss the announcement of a Rakuten Affiliate API version bump and every query returns zero results, so every article keeps getting published with the hgc fallback. The pattern is that you notice after several days of "articles are being published, but not one has an individual product link." Schedule a check — via cron or by hand — every six months to confirm the current endpoint on Rakuten's developer portal.

⑨ blogsync's path doesn't resolve in the launchd environment

BLOGSYNC="${BLOGSYNC:-$HOME/.local/bin/blogsync}"

at line 13 of post-to-hatena.sh

looks for ~/.local/bin/blogsync

when the environment variable is unset. If installed via Homebrew it lives at /opt/homebrew/bin/blogsync

. launchd's PATH only has about /usr/bin:/bin

, so neither path resolves. blogsync: command not found

appears and every post fails, but the --all

loop continues and the script itself ends with exit 0

. The log just says "投稿: 0本," which makes it hard to notice as an error. Write BLOGSYNC=/opt/homebrew/bin/blogsync

as a full path in the launchd plist's EnvironmentVariables

, or specify the BLOGSYNC

environment variable explicitly in .env

.

⑩ Forget shopt -s nullglob and the loop runs once with "zero files"

Line 34 of audit-heal.sh

sets shopt -s nullglob

so an empty glob returns an empty array before entering the for loop at line 35, then restores it with shopt -u nullglob

at line 44. Forget this nullglob

and, even with no files for the day in published/

, the shell runs the loop once with the literal string "$ARCHIVE/2026-06-23_*.md"

, and [ -e "$f" ]

fails and skips it. The result is that it correctly reaches "⚠ 公開が目標未達" with published=0

, but the trace of the loop having run remains in the log and causes confusion. Make it a habit to always set shopt

in pairs around for loops that use globs.

⑪ macOS Focus mode swallows osascript notifications

notify()

at line 16 of audit-heal.sh

sends a macOS notification via osascript -e "display notification ..."

. If Focus on macOS 15+ is set to "Sleep," the notification isn't shown as a banner and only accumulates in Notification Center. I thought I was checking it at 7 a.m., but I wouldn't see it unless I opened Notification Center. I recommend setting StandardOutPath

/ StandardErrorPath

in the plist to write logs to files, or having a secondary channel like a Slack Webhook or LINE Notify.

⑫ StartCalendarInterval is skipped while macOS is asleep

launchd's StartCalendarInterval

doesn't fire the job when macOS is asleep past the scheduled time. There are cases where a missed run fires at login after wake, but it depends on timing. A few days a month, "it was supposed to run at 5 a.m. but didn't run until 10." The idempotent design means the noon run compensates and articles converge to 3–5 a day, but the timing shifts. The root fix is either running a MacBook in clamshell mode (lid closed, display connected) permanently on, or migrating to an always-on environment like a Mac mini or a VPS.

These are the design guidelines drawn from a year of operation and several incidents. If you're building a new system, design with these in mind from the start.

1. Treat secret files as sanctuaries outside automation's reach from day one

Automation scripts must never rewrite .env

or ~/.config/blogsync/config.yaml

. From the beginning, design an explicit whitelist of files that self-healing, watchdog, and backup scripts are allowed to rewrite, and don't touch anything else. The cause of "it was working, then commissions suddenly went to zero" is almost always the loss of a secret. Secrets kept out of version control by gitignore can't be restored with git checkout

once a script accident wipes them.

2. Share TARGET across all scripts via a single environment variable

Write AFFILIATE_FACTORY_TARGET=5

in .env

and unify every script to read TARGET="${AFFILIATE_FACTORY_TARGET:-5}"

. Divergences like daily.sh's hardcoded TARGET=5

and audit-heal.sh's default of 3

keep producing audit misjudgments. The correct state is one where changing the number in one line of .env

propagates everywhere.

3. grep for affiliate links every morning to eradicate silent zero-revenue

In addition to grep -q 'hb.afl.rakuten.co.jp' "$f"

in audit-heal.sh

, add logic that explicitly detects bare URLs as "non-affiliate" via grep -q 'search.rakuten.co.jp' "$f"

. Detecting only the absence of hb.afl

means that when .env

vanishes and search.rakuten.co.jp

(zero commission) slips in, it's still judged OK. Design it so a macOS notification arrives the moment a bare URL appears and you'll notice within a day.

4. Set GEN_TIMEOUT to at least 1.5× the measured time. Don't be cheap here

Average generation time for 8,000–10,000 characters plus WebSearch on three competitors is 12–15 minutes. GEN_TIMEOUT=1200

(20 minutes) is that with 1.5× headroom built in. Shorten the timeout and you lock in the chain "killed mid-generation → empty response → 3 failures → 0 articles." The loss from publishing zero is reliably larger than the generation cost. When you change to a new prompt design, run it manually a few times and re-measure the elapsed time.

5. Don't remove the enforced output format (the PRODUCT: line)

Both resp_is_valid()

and product-name extraction depend on the ^PRODUCT:

line. Remove that one enforced output line while rewriting the prompt and all the parsing collapses. Changes to the output format require verifying the impact across the whole script. If you do change it, dry-run with AFFILIATE_FACTORY_TEST_RESPONSE

(below) before going to production.

6. Always build in a dry run with AFFILIATE_FACTORY_TEST_RESPONSE

This is the mock-response mechanism at lines 265–266 of generate.sh

. Every time you change the prompt, modify postprocess_body

, or add link-replacement logic, you can run the full path at zero API cost.

export AFFILIATE_FACTORY_TEST_RESPONSE='PRODUCT: テスト掃除機 X100

> ※本記事はアフィリエイトプログラム(楽天アフィリエイト等)を利用しています。

[:contents]

## この記事でわかること'
bash generate.sh 1

The command above exercises the whole path: Rakuten link replacement, file output, and appending to posted-products.log

. It prevents the mistake of running it for the first time under production launchd and locking in "zero articles the next morning."

7. Limit self-healing scope strictly with a whitelist, and write only via atomic replacement

Explicitly enumerate the files self-healing may rewrite. Every write operation should use exactly one pattern: "temp file → atomic swap with mv

." Never use some_command > "$TARGET_FILE"

. Use some_command > "$TARGET_FILE.tmp" && mv "$TARGET_FILE.tmp" "$TARGET_FILE"

. When the order of shell variable expansion and redirection doesn't line up, a file at an unintended path gets overwritten. That's exactly what destroyed .env

and post-to-hatena.sh

all at once.

8. Build launchd job liveness checks into the audit

if ! launchctl list 2>/dev/null | grep -q 'affiliate-factory'; then
  log "  ⚠ launchdジョブが未登録。plistを確認してください"
  problems=$((problems+1))
fi

Add this check to audit-heal.sh

and you'll always detect a corrupted plist and unregistered job by the next morning. It's the minimum guard against a repeat of the failure I didn't notice for two weeks.

9. Syntax-check launchd plists with plutil and keep them under version control

plutil ~/Library/LaunchAgents/com.affiliate-factory.daily.plist

plists are XML. A missing closing tag or stray characters around a <key>

breaks them. plutil

reports syntax errors with line numbers. Fix the procedure: every time you change the code, run it through plutil

, then reload with launchctl unload && launchctl load

. Take plists out of .gitignore

and version them. Whether or not you can restore from git makes an hour's difference in recovery time from the moment you notice.

10. Specify blogsync's path as a full path via the BLOGSYNC environment variable, and write it in the launchd plist too

Write BLOGSYNC=/opt/homebrew/bin/blogsync

(or ~/.local/bin/blogsync

) explicitly in launchd's EnvironmentVariables

. Put the output of which blogsync

straight into the plist. Depend on how PATH

happens to be set and you'll suddenly hit command not found

on a macOS upgrade or a Homebrew prefix change (Intel→Apple Silicon migration).

11. Make --model an environment variable so you can switch models without redeploying

Change --model sonnet

at line 272 of generate.sh

to --model "${AFFILIATE_FACTORY_MODEL:-sonnet}"

. When you want to cut costs, just write AFFILIATE_FACTORY_MODEL=claude-haiku-4-5-20251001

in .env

and it switches from the next run. Temporarily bumping only certain genres (high-value, technical) up to Opus 4.8 also becomes possible without touching code.

12. Rotate posted-products.log monthly to manage prompt cost

tail -n 200 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"

Keeping just the most recent 200 entries gives plenty of exclusion effect and cuts token cost to under 1/9. Raising token costs in the pursuit of monthly revenue is backwards. Log rotation is part of cost management for a monetization system.

13. Don't rely on notifications alone — check logs/audit-YYYY-MM-DD.log weekly

Every morning's full audit results remain in audit-heal.sh

's AUDIT_LOG="$DIR/logs/audit-${TODAY}.log"

. Even on a day when the macOS notification was skipped by Focus mode, the log tells you how many articles were published. Once a week, run grep '✅\|❌' logs/audit-*.log | tail -14

to check the OK/NG rate for the last two weeks. If there are days with consecutive NGs, that day's log lets you trace the cause.

Part 1 covered the system's skeleton (launchd → daily.sh → generate.sh → post-to-hatena.sh → audit-heal.sh), and this time I published the full back half — the machinery that reliably books revenue.

Evaluating this system along two axes, design and failure:

On design, the core is the two pillars of idempotency and self-healing. Because the idempotent design converges on TARGET articles per day no matter how many times it runs, a morning failure is automatically covered at noon. Because audit-heal.sh runs every morning, detects anomalies, and tells the human via macOS notification, the lag before I notice a problem stays within a day. The time I spend checking is under three minutes each morning.

On failure, the lesson is to build the risk of "automation breaking itself" into the design from the start. The vanished .env

, the runaway self-healing, the corrupted launchd plist — all of them were cases of "automation that was supposed to be running" quietly breaking beneath the surface. The state persists undetected unless you look at the logs, and by the time you notice, commissions are at zero or two weeks of publishing opportunity are gone. Design on the assumption that things break, and have a mechanism that guarantees you detect the breakage by the next morning — that's the lifeline of long-term operation.

Most of my 1.2M yen in monthly revenue accumulates while I sleep. This setup is one of its core pieces, and the result of a six-month investment in "building an environment." The decisive difference from a side business that sells time isn't reducing writing time to zero — it's building a state where machines keep running automatically during the hours when I can't create value.

Next time I'll write about the design of the whole Claude Code autonomous environment that includes this affiliate factory — resource management when running multiple factories in parallel, model routing, and monthly cost management.

I've written up the full picture, the breakdown of the 1.2M yen/month, and the 30-day procedure in a paid note.

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート

*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio · X · GitHub*

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/two-weeks-of-silent-…] indexed:0 read:36min 2026-08-15 ·