cd /news/ai-agents/0-of-3-articles-published-for-3-days… · home topics ai-agents article
[ARTICLE · art-97497] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

0 of 3 Articles Published for 3 Days Straight: The 41-Second Timeout Margin That Killed My Automation

A developer built an autonomous Claude Code environment that publishes three affiliate articles daily to Hatena Blog, recovering from a 41-second timeout margin that caused three days of zero publications. The system uses four shell scripts and macOS launchd to run three times a day, with idempotency ensuring the daily publish count converges to three. The developer reports monthly revenue above 1.2M yen from this automation.

read31 min views1 publishedAug 15, 2026

For three mornings in a row, my audit log printed the same line: published today: 0 / target: 3

. Nothing crashed. The scripts ran, exited, and produced nothing. The entire cause turned out to be a 41-second margin — a 300-second timeout against a process that actually takes 259 seconds. Changing one number to 600 turned 0/3 into 3/3 the next morning.

Some background: I went from earning 100k yen a month as a university student to 600k a month juggling multiple gigs, then lost all of it overnight to a company-initiated layoff. Over the following six months I built an autonomous Claude Code environment, and I'm now above 1.2M yen in monthly revenue. At the core of it is a system that publishes three affiliate articles every morning without a human touching anything.

The difference between people who keep earning from affiliate marketing and people who drop out is not writing skill, and not a nose for picking products. It's whether you can keep going.

Articles that tend to earn on Rakuten Affiliate share a common pattern: spec-comparison articles about home appliances and gadgets priced above 50,000 yen, with lots of reviews and in stock. Robot vacuums, portable power stations, heat-pump washer-dryers, fully automatic coffee makers. The search intent is "I want to compare before I buy," so product link click-through is high and it fits the structure of affiliate marketing well.

The problem is cost. Researching the specs of a high-ticket appliance on the web, building a comparison table, and finishing an article good enough to include the "honestly weak points" section takes 30 to 40 minutes. Three articles is close to two hours. Almost nobody has the willpower to repeat that 365 days a year. I don't either.

What you need here isn't "trying harder" — it's an environment that keeps running even when you don't try hard.

Once the system is built, the running cost is just API calls. The affiliate-factory

I built is a simple structure made of four shell scripts. macOS launchd

(the successor to cron) fires three times a day — morning, midday, and night — and three affiliate articles get published to Hatena Blog every day without any human involvement.

There's one more design-level core idea: idempotency.

A naive script doesn't care how many articles have already been published today. If the morning batch fails, the day ends at zero. This system first counts "how many were successfully published today" and "how many drafts are left on the Desktop," and generates only the number still missing against the target of three.

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

Even if the morning batch is wiped out by API limits, the midday batch calculates "we still need 3 today" and refills. The evening batch fills the last one. No matter how many times it runs, the day's publish count converges to three. Once you understand this design, the roles of the four scripts look completely different.

I assume many readers are in the situation of "having to write an article every day is exhausting." I was too. But to be precise, what's exhausting is "making the decision to write an article every day." When the system takes over the decision, the human just looks at the published articles.

Here's the structure of the whole system as an ASCII diagram.

[launchd] 毎朝・昼・夜の3回
    │
    ▼
[daily.sh]  ← 司令塔。冪等に「今日あと何本必要か」を計算
    │
    ├─ NEED本分ループ ──────────────────────────────────────────┐
    │                                                          │
    │   [generate.sh]                                          │
    │       │  claude -p + WebSearch で製品を選び記事を生成      │
    │       │  timeout 600s / 最大3リトライ                     │
    │       └──→ ~/Desktop/アフィリ記事/YYYY-MM-DD_HHMMSS.md ──┘
    │
    ├─ [post-to-hatena.sh --publish --all]
    │       │  Desktop/*.md を blogsync ではてなブログへ全件公開
    │       └──→ published/ へアーカイブ(Desktopキューから除去)
    │
    └─ [audit-heal.sh]
            │  壊れ記事の掃除、カバレッジ表の出力、異常時はmacOS通知
            └──→ logs/audit-YYYY-MM-DD.log

The role of daily.sh

is simple. Calculate what's left for today, call generate.sh

only as many times as needed, then run publishing and auditing in order. That's it.

TARGET=3
TODAY="$(date +%Y-%m-%d)"

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

if [ "$NEED" -gt 0 ]; then
  for i in $(seq 1 "$NEED"); do
    bash "$DIR/generate.sh" "$i" || echo "[daily] ⚠ 生成1本失敗(後続の再実行で補充されます)。"
  done
fi

bash "$DIR/post-to-hatena.sh" --publish --all
bash "$DIR/audit-heal.sh"

PUB_TODAY

counts the files under published/

carrying today's prefix. DRAFTS

is the number of drafts still sitting directly on the Desktop. NEED

is the difference, clamped to 0 if it goes negative.

The important part is that processing doesn't stop when one generate.sh

run fails. || echo

swallows the error and a later batch refills the remainder. set -uo pipefail

is declared at the top, while individual generation failures are absorbed inside the loop. The design keeps the whole flow alive without losing track of what happened.

generate.sh

is the heart of this system. Using WebSearch, it picks a high-ticket appliance that sells well on Rakuten, researches the specs, and writes the article. Claude does all of that on its own.

Prompt structure

The prompt is defined in a heredoc inside generate.sh

. It instructs Claude through the following steps.

The EXCL

variable at the top of the prompt holds the list of already-covered products. It's read from posted-products.log

and handed to Claude as "do not pick these products this time," so the same product doesn't come up repeatedly.

Invoking the claude command

GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}"

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

Each of these three flags exists for a reason.

** </dev/null** — a process launched from launchd has no tty. Without this,

claude -p

keeps waiting on stdin and hangs. It's a trap you never notice when running manually from a terminal; it only reproduces under launchd.** --permission-mode auto** — when using WebSearch, Claude normally asks for confirmation to use it. If that prompt appears in an environment with no tty, it hangs with no response ever returning. Specifying

auto

makes the tools allowed via --allowedTools

execute without confirmation.** timeout 600** — this is the main story. The flow of generating an article while running WebSearch multiple times takes about 260 seconds in practice. I originally set it to 300 seconds. And for the first three days, nothing got published at all.

I cover the cause and the permanent fix in detail later in this article.

Generating Rakuten affiliate links

The other important job of generate.sh

is embedding Rakuten affiliate links correctly.

The "search on Rakuten" links Claude writes into the article don't carry affiliate tracking as-is. So the postprocess_body

function post-processes the body in Python and replaces every Rakuten link Claude wrote.

rakuten_md_link_re = re.compile(r"\[楽天で「[^」]*」を探す\]\([^)]*\)")
body = rakuten_md_link_re.sub(lambda m: link, body)

The links themselves are retrieved via Rakuten's Ichiba Item Search API. However, the Rakuten API has a keyword is not valid

error, and passing a product name like "Narwal Freo Z Ultra" verbatim can get rejected. As a countermeasure, the resolve

function retries while dropping trailing words one at a time.

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)
        result = fetch(keyword)
        if result:
            if not brand or brand in (result["name"] + " " + result["url"]).lower():
                return result["url"]
            return None
        time.sleep(1.0)
    return None

It tries "Narwal Freo Z Ultra" → "Narwal Freo Z" → "Narwal Freo" in order, and verifies whether the matched product name contains the brand name ("narwal"). If it decides the result morphed into a different product, it falls back to a search URL with affiliate tracking (an hgc link). Even when an individual product link can't be obtained, the design guarantees a revenue-carrying search link goes in.

Posting the article drafts to Hatena Blog is the job of post-to-hatena.sh

. The backend is blogsync

(a Hatena Blog CLI written in Go).

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

  echo "[hatena] 投稿: ${DRAFT_FLAG:-公開} | $title"
  printf '%s\n' "$body" | "$BLOGSYNC" post $DRAFT_FLAG --title "$title" "$BLOG"
}

It extracts the first line of the Markdown (# Title

) as the entry title and posts the body with the H1 removed. If the H1 stays in the body, Hatena renders it twice.

Run with the --publish --all

flags, it publishes every draft on the Desktop in one shot and moves published files into the published/

directory, removing them from the Desktop queue. This is so that when the next day's batch counts "drafts left today," yesterday's files don't get mixed in.

Posted paths are recorded in posted-hatena.log

and used for skip decisions during --all

runs. Files listed in the log are excluded from posting, so the same article is never double-posted.

Leftovers from failed generation (files whose body contains "不明な商品" or "Request timed out") are also detected and skipped before posting.

if /usr/bin/grep -qE '不明な商品|Request timed out' "$f"; then
  echo "[hatena] スキップ(生成失敗の残骸): $f" >&2; continue
fi

This is the safety valve that prevents posting a failure file when Claude couldn't produce a decent article even after three attempts.

The last step is audit-heal.sh

. It runs after the day's publishing finishes and does three things.

1) Cleaning up broken articles

It deletes from the queue the failed-generation leftovers that post-to-hatena.sh

skipped.

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

2) Printing a coverage table

For every article published today, it verifies that affiliate links are properly included.

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

The decision is made on whether hb.afl.rakuten.co.jp

appears in the body. If it doesn't, it's counted as a problem, marked "✗非アフィリ" (non-affiliate). A review article published without an affiliate link isn't noticeable to readers, but revenue goes to zero. This is one of the blind spots of automation.

3) macOS notifications

If the target count wasn't reached, or if there are articles with non-affiliate links, it fires an alert into macOS Notification Center.

notify() {
  command -v osascript >/dev/null 2>&1 && \
  osascript -e "display notification \"$1\" with title \"アフィリ自動投稿\"" >/dev/null 2>&1 || true
}

When a notification arrives, I check logs/audit-YYYY-MM-DD.log

. Which article had the problem and how many were published are both recorded there.

The overall audit summary is printed in this format.

==== アフィリ監査 2026-06-22 07:15 ====
  --- 本日公開分のカバレッジ ---
    ✓アフィリ | Roborock S8 MaxV Ultra レビュー...
    ✓アフィリ | Anker SOLIX C800 ポータブル電源...
    ✓アフィリ | Narwal Freo Z Ultra 実機スペック...
  --- サマリ ---
  本日公開: 3本 / 目標: 3本 | 未公開キュー残: 0本 | 非アフィリ: 0本
  ✅ 監査OK: 3本すべてアフィリリンク付きで公開

This accumulates in logs/

every morning around 7am. The more these logs pile up, the more trust builds that the system is actually running.

Later in this article I show, with real code, why audit-heal.sh

kept recording "published: 0 / target: 3" for the first three days, and how changing one number fixed it for good.

resp_is_valid

protects The resp_is_valid

function at the core of generate.sh

is a short four-line implementation, but without it, broken articles pile up on the Desktop.

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
}

There are three conditions.

① A PRODUCT: line exists — this is the marker sed uses downstream to extract the product name. Even though the prompt instructs "the first line must contain only

PRODUCT: <product name>

," Claude sometimes adds a preamble, or an API limit returns nothing but a message. Writing out a response with no PRODUCT:

as an article appends the product name as "不明な商品"

(unknown product) to posted-products.log

, polluting the next day's deduplication list. A Hatena article titled "Review of an unknown product" is not funny.② No error text is included — a Claude process killed by timeout

can return a short response containing "Request timed out." An API limit produces "rate limit" or "usage limit." Writing that out as an article means post-to-hatena.sh

skips it via the grep -qE '不明な商品|Request timed out'

guard, and it stays in the Desktop queue until audit-heal.sh

cleans it. The guards are layered, but rejecting it in resp_is_valid

is the root-level fix.

③ At least 400 characters — WebSearch sometimes whiffs and returns just a few lines along the lines of "no matching product found." Since one article's worth of Markdown is at minimum 1,500 to 3,000 characters, anything under 400 is a clear failure. This threshold is empirical, and it catches every response too short to be an article.

Only when all three conditions are met does it break

and move to the next step; if even one fails, RESP=""

is set and the attempt

counter advances. When all three attempts fail, generate.sh

exits 1 without writing a broken article.

PRODUCT:

line as an I/O contract The most important instruction in the prompt is the constraint: "the first line must contain only PRODUCT: <official product name>." Why does the article need this line at the top?

PRODUCT=$(printf '%s\n' "$RESP" | sed -n 's/^PRODUCT: *//p' | head -1)
BODY=$(printf '%s\n' "$RESP" | awk 'p{print} /^PRODUCT:/{p=1}')
[ -z "$BODY" ] && BODY="$RESP"
[ -z "$PRODUCT" ] && PRODUCT="不明な商品"

The PRODUCT:

line is a separator that reliably splits "product name" from "body" in Claude's response. Even if you make it return a structure of "line 1 is the H1 title, body starts at line 2," a preamble can still slip in. By setting an explicit PRODUCT:

marker, awk

raises a flag (p=1

) when it hits the PRODUCT:

line and extracts only the lines after it as the body.

The extracted PRODUCT

does three jobs: it's passed as the keyword to the Rakuten API, appended to posted-products.log

to join the next day's deduplication list, and used as the product name for generating the affiliate link. This single line is the starting point for every downstream step.

postprocess_body

performs The post-processing function postprocess_body

in generate.sh

embeds a bit over 30 lines of Python. This is the trickiest part, and it does more than it looks like.

First it forcibly normalizes the Markdown structure.

lines = body.splitlines()
title_idx = next((i for i, line in enumerate(lines) if line.startswith("# ")), None)
title = lines.pop(title_idx) if title_idx is not None else f"# {product} レビュー"

lines = [line for line in lines if line.strip() not in {disclaimer, contents}]

No matter which line Claude puts the H1 on, pop

lifts it out. If the disclosure text (> ※本記事はアフィリエイト…

) or the table-of-contents tag ([:contents]

) got included twice, they're removed. Finally out = [title, disclaimer, contents]

fixes the first three lines. Whatever layout the body comes back in, the output always starts with "H1 title → disclosure → TOC tag."

The next three substitutions are the main event.

Substitution ①: replacing placeholders

placeholder_re = re.compile(r"(?▼?楽天で「[^」]+」を検索してリンク(?:を作成し、ここに貼る|を貼る))?")
body = placeholder_re.sub(link, body)

Claude sometimes writes the link as "a placeholder a human should fill in later," in the form (▼楽天で「Roborock S8」を検索してリンクを貼る)

. This picks that up and swaps in the correct affiliate link.

Substitution ②: replacing Rakuten Markdown links (most important)

rakuten_md_link_re = re.compile(r"\[楽天で「[^」]*」を探す\]\([^)]*\)")
body = rakuten_md_link_re.sub(lambda m: link, body)

When the prompt says "write it in the format [楽天で「<product name>」を探す](<URL>)

," Claude follows the format, but the URL ends up being Rakuten's ordinary search page URL (https://search.rakuten.co.jp/search/mall/...

). As-is, that's a non-affiliate link with no tracking. This regex overwrites every Markdown link beginning with [楽天で「…」を探す]

with the correct affiliate URL. It's the most important substitution — the hb.afl.rakuten.co.jp

check in audit-heal.sh

passes because this works correctly.

Substitution ③: a catch-all replacement for Rakuten-domain links

rakuten_any_re = re.compile(r"\[[^\]]+\]\((?:https?:)?//[^)]*rakuten\.co\.jp[^)]*\)")
body = rakuten_any_re.sub(lambda m: link, body)

This is the last line of defense for cases where Claude writes a Rakuten URL in some other form inside a comparison table or the summary section. Every Markdown link containing rakuten.co.jp

gets unified into the affiliate link.

This system has two deduplication logs.

** posted-products.log** — the file that accumulates generated product names.

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

paste -sd '、'

joins all lines into a single comma-separated line and embeds it into the exclusion section of the prompt. If "Roborock S8 MaxV Ultra" is in there, Claude won't pick the same product again the next day. After a few months of accumulation, the "same article shows up again" problem effectively disappears.

** posted-hatena.log** — the file that accumulates file paths already posted to Hatena.

if /usr/bin/grep -qxF "$f" "$POSTED_LOG"; then continue; fi

It combines -x

(whole-line match) and -F

(fixed string). daily.sh

runs three times a day and calls post-to-hatena.sh --all

every time. Without this log, the same file would be posted three times. -F

is specified so that dots and slashes in file paths aren't interpreted as regex.

For the first three days, the audit-heal.sh

log looked like this.

==== アフィリ監査 2026-06-03 07:15 ====
  --- 本日公開分のカバレッジ ---
  --- サマリ ---
  本日公開: 0本 / 目標: 3本 | 未公開キュー残: 0本 | 非アフィリ: 0本
  ⚠ 公開が目標未達(生成 or 公開が失敗した可能性)
  ❌ 監査NG: 要確認 (1件)

Zero for three days straight. It should have been computing NEED

and calling generate.sh

, yet not a single draft had been created on the Desktop.

At first I thought "the prompt is bad." I added more product categories and made the output format instructions more detailed. Nothing changed. Next I suspected "maybe it's a tool permission issue" and reviewed the --allowedTools

settings. No change.

On the night of the third day, I ran daily.sh

directly from the terminal. Thirty minutes later, one article was finished. It was failing only via launchd.

As I dug into the difference between launchd and manual execution, the comment left in generate.sh

after the fix caught my eye.

GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}"

Measured 259 seconds → timeout 300 seconds. The margin was only 41 seconds.

The flow of generating an article while calling WebSearch three or four times takes about 259 seconds when measured in a terminal. 300 seconds looks like it has room. But a process under launchd carries slightly more startup overhead, and when it overlaps with the 7am hour where WebSearch responses are somewhat slower, 259 seconds can become 305. When timeout

kills it, it moves on to the next attempt with RESP=""

, and after three empty responses it exits 1. That went on for three days.

The fix was just changing the value of GEN_TIMEOUT

from 300

to 600

. The next morning's log looked like this.

==== アフィリ監査 2026-06-06 07:31 ====
    ✓アフィリ | Roborock S8 MaxV Ultra レビュー...
    ✓アフィリ | Anker SOLIX C800 ポータブル電源...
    ✓アフィリ | Narwal Freo Z Ultra 実機スペック...
  本日公開: 3本 / 目標: 3本 | 未公開キュー残: 0本 | 非アフィリ: 0本
  ✅ 監査OK: 3本すべてアフィリリンク付きで公開

Confirming that it works manually is not enough — verifying that you get the same result via launchd is the completion condition for automation. From this lesson I externalized AFFILIATE_FACTORY_GEN_TIMEOUT

as an environment variable, so it can be adjusted without touching code.

There's also a reflection on "why I didn't notice for three days." When generate.sh

returns exit 1, daily.sh

absorbs the error with || echo

and continues.

bash "$DIR/generate.sh" "$i" || echo "[daily] ⚠ 生成1本失敗(後続の再実行で補充されます)。"

That design was a deliberate choice for idempotency: "even if the morning fails, midday refills it." But at the same time it created a blind spot — "even when it fails every single time, processing doesn't stop and no macOS notification goes up." audit-heal.sh

fires the notification for 0 articles published at the very end of the process, so the notification wasn't reaching me via launchd (when I checked later, they had piled up in Notification Center).

keyword is not valid

A week after the system started running stably, when an article for the Narwal Freo Z Ultra robot vacuum was generated, the log kept printing [generate] 商品個別リンクを取得できず検索リンクにフォールバック: Narwal Freo Z Ultra

(couldn't get individual product link, falling back to search link).

Passing keyword: "Narwal Freo Z Ultra"

verbatim to the Rakuten Ichiba API returns HTTP status 400. The error body contains the string keyword is not valid

.

except HTTPError as exc:
    body = exc.read().decode("utf-8", "replace")
    if exc.code == 400 and "keyword is not valid" in body:
        return None  # → resolve()の語削りループへ

That return None

is the trigger for the word-dropping retry. The single-character token "Z" in "Narwal Freo Z Ultra" was the problem. "Narwal Freo Z" gets rejected too. "Narwal Freo" finally goes through — and then the next problem happens.

Searching "Narwal Freo" sorted by review count descending hits the currently most popular model. If that's the "Narwal Freo Ultra," the brand name "narwal" is in the product name, so it passes the brand guard. The original target was "Narwal Freo Z Ultra," yet a link to a different model gets embedded.

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

The current implementation only filters on the brand name (the first word). Since it just checks whether the string "narwal" is contained in the result, a different model from the same brand slips through. The hgc fallback URL does carry affiliate tracking, so revenue doesn't go to zero, but the accuracy of individual product links remains an issue.

One piece of spec knowledge came out of this problem. The Rakuten API rejects keywords containing single-character tokens with keyword is not valid. Product names with "Z," "S," "X," "i," and similar — common in model numbers — will always trip it. The word-dropping loop in

generate.sh

is mandatory.With the first launchd

configuration, generate.sh

never finished no matter how many minutes I waited after the batch started. The claude process existed in the process tree, but nothing was happening.

ps aux | grep claude

The claude process is definitely there. But it isn't running.

In a launchd environment with no tty, the claude command was waiting for input from stdin. In a terminal, user input can be received from /dev/tty

, so claude -p

decides "no interactive input needed" and proceeds. Under launchd there's no /dev/tty

, so it entered a mode of waiting for something to arrive on stdin.

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

</dev/null

immediately makes stdin EOF. Adding that one thing stopped the hang.

--permission-mode auto

was needed at the same time. When using WebSearch, Claude shows a confirmation prompt asking "may I use WebSearch?" In an environment with a tty, a human can type "yes," but with stdin closed via </dev/null

there's no response to that prompt and it hangs somewhere else. Specifying auto

makes the WebSearch explicitly listed in --allowedTools

execute without confirmation.

These two flags only work as a set. With only </dev/null

, it hangs at the permission prompt. With only --permission-mode auto

, it hangs on stdin. Neither one alone solves it. Only with both does it behave under launchd the same way it does in a terminal.

Next time, I'll publish the self-healing canaries (a happy-path test and a deliberate-failure test) I implemented to keep this system running, plus the actual uptime and revenue data readable from three months of logs.

The previous chapter covered the three big failures: timeout 300 seconds losing to a measured 259, the stdin hang under launchd, and the Rakuten API's keyword is not valid

. Below is a list of the smaller traps I actually stepped on beyond those. They reveal "why it's built this way," so they're also useful as design references if you build your own.

macOS wc -l outputs leading spaces

This is one of the causes that breaks the idempotency calculation in daily.sh

.

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

macOS wc -l

outputs with leading spaces, like " 3"

. Without tr -d ' '

, you get NEED=$((TARGET - " 3" - DRAFTS))

and bash's arithmetic expansion returns an error. GNU Linux's wc

has no spaces, so you never notice at all in a local Linux environment — the symptom first appears in the macOS launchd environment. One added line fully resolves it.

Without shopt -s nullglob, the glob remains as a literal string

In the today's-publications loop in audit-heal.sh

, when there are no files for that day:

shopt -s nullglob
for f in "$ARCHIVE/${TODAY}"_*.md; do
  published=$((published+1))
done
shopt -u nullglob

Without shopt -s nullglob

, when the glob matches nothing, the pattern string "$ARCHIVE/2026-06-06_*.md"

itself enters the loop. You can check existence with [ -e "$f" ]

, but the implementation can end up incrementing the published

counter by 1 unintentionally. Always restore scope with shopt -u nullglob

when you're done. Applying it globally silently empties other globs.

The -x and -F in grep -qxF come as a set

if /usr/bin/grep -qxF "$f" "$POSTED_LOG"; then continue; fi

Without -x

(whole-line match), /Desktop/アフィリ記事/2026-06-06_071532.md

falsely matches as a substring for 071532.md

. Without -F

(fixed string), the .

in the path is interpreted as regex "any character." Missing either one still works in most cases, so it's hard to catch in unit tests — it becomes an intermittent failure where double-posting happens only for particular filenames.

launchd's PATH is a different thing from the terminal's

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

The PATH

of a launchd-started process is roughly /usr/bin:/bin:/usr/sbin:/sbin

. It doesn't include anything under nvm (~/.nvm/versions/node/vXX/bin/

) or ~/.local/bin/

. Even if which claude

resolves in your terminal, via launchd the generation phase is wiped out by command not found

. Writing defaults as absolute paths and overriding environment differences via .env

or EnvironmentVariables

is the most reliable design.

Drop --publish and drafts pile up on the Desktop, making NEED permanently 0

[ -z "$DRAFT_FLAG" ] && mv "$f" "$ARCHIVE/" && echo "[hatena] アーカイブへ移動: $(basename "$f")"

The mv

to the archive only happens with --publish

. With --draft

(the default), files stay on the Desktop forever. If a misconfiguration drops --publish

, the DRAFTS

count keeps piling up on subsequent days and NEED

is always calculated as 0. Every day it decides "0 articles need generating," nothing happens, and only audit-heal.sh

keeps firing "target not met" notifications. The nasty part is that the symptom is indistinguishable from a timeout failure.

Without minPrice: "30000", replacement parts become the link target

"minPrice": "30000",
"NGKeyword": "中古 訳あり 美品",

Searching for "Roborock S8 MaxV Ultra" without minPrice

can put replacement dust bags and dedicated cleaning fluid at the top of the review-count ordering. A 30,000-yen floor suppresses false hits on consumables and accessories. The gap versus the prompt instruction of "above 50,000 yen" is headroom for cases where the actual selling price on Rakuten falls below list price. Setting minPrice: "50000"

easily produces zero hits for out-of-stock or older models, which increases the frequency of hgc fallbacks.

2>/dev/null

makes claude's error messages disappear entirely

RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" ... 2>/dev/null)

It's there to suppress stderr noise in the launchd environment, but as a side effect, messages for timeout kills, auth errors, and model errors are all thrown away. When debugging, temporarily change it to 2>/tmp/claude-err.log

to see what's actually happening. Be sure to change it back to 2>/dev/null

when returning to production. Since the check in resp_is_valid

is the only error-detection mechanism, error content has to be judged from the contents of the response string.

A test response without the PRODUCT: separator pollutes the product name

PRODUCT=$(printf '%s\n' "$RESP" | sed -n 's/^PRODUCT: *//p' | head -1)
[ -z "$PRODUCT" ] && PRODUCT="不明な商品"
[ "$PRODUCT" != "不明な商品" ] && echo "$PRODUCT" >> "$LOG"

When feeding a test response directly via AFFILIATE_FACTORY_TEST_RESPONSE

, forgetting the PRODUCT:

line makes the product name "不明な商品"

. Appending to posted-products.log

is skipped by the != "不明な商品"

check, but the keyword "不明な商品"

gets passed to the Rakuten API, leading to keyword is not valid

→ hgc fallback. An article with an hgc link and a nonsensical product name stays on the Desktop. The first line of a test response must always be PRODUCT: テスト商品名

.

Without seconds in the filename timestamp, fast tests collide

FNAME="$OUT/$(date +%Y-%m-%d_%H%M%S).md"

In production each article takes about 260 seconds, so there are no collisions at minute granularity. But with AFFILIATE_FACTORY_TEST_RESPONSE

, three articles are generated in seconds when NEED=3. With %H%M

(minute granularity), the second article within the same minute overwrites the first. Using %H%M%S

(second granularity) prevents collisions even in fast tests.

The Rakuten API brand guard lets a different model of the same brand slip through

if not brand or brand in (result["name"] + " " + result["url"]).lower():
    return result["url"]

brand

is the first word of the product name (e.g. "narwal"

). When "Narwal Freo Z Ultra" drops words down to "Narwal Freo" and hits, the "Narwal Freo Ultra" with the most reviews may be returned. The string "narwal" is contained in both product names, so it passes the brand guard. A link to a different model from the original target gets embedded, but since it's an affiliate link for the same brand, revenue still applies. Fully preventing it requires model-number matching; for now the hgc fallback operates as the final safety valve.

Getting the split between set -uo pipefail and || echo wrong stops everything

set -uo pipefail
bash "$DIR/generate.sh" "$i" || echo "[daily] ⚠ 生成1本失敗(後続の再実行で補充されます)。"

The set -uo pipefail

at the top of daily.sh

makes the whole script fail-fast. On the other hand, the generate.sh

call absorbs errors with || echo

. This split is intentional design, expressing "I don't want to stop the overall flow (publishing, auditing), but I'll tolerate individual generation failures." Changing || echo

to || true

erases the failure message and leaves you unable to tell what happened from the logs.

Design principles distilled from more than three months of live operation.

1. Set timeouts to at least 2× the measured value, and externalize them as environment variables

GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}"

Against a measured 259 seconds, 300 seconds is easily exceeded by launchd startup overhead plus morning external API response delays combined. "At least 2× measured, and environment-variable-ized" is the iron rule. You can restart operation by changing one line in .env

. No need to touch code.

2. The launchd-specific two-piece set is " </dev/null + --permission-mode auto"

Neither one works alone. </dev/null

alone hangs at the WebSearch permission prompt. --permission-mode auto

alone hangs on stdin. This applies to every situation where you run a CLI in an environment with no interactive input (gh

, git commit

, and interactive-prompting tools in general).

3. Design the idempotent NEED calculation first

NEED = TARGET - PUB_TODAY - DRAFTS

Without the property that "the day's publish count converges no matter how many times it runs," a three-times-daily launchd batch is a breeding ground for double-publishing or over/under counts. Idempotency is the foundation of automation. Failure recovery also collapses into one sentence: "the next batch automatically fills the rest."

4. Defend against broken articles in three stages

Stage 1: resp_is_valid

(right after generation) → Stage 2: grep -qE '不明な商品|Request timed out'

(right before posting) → Stage 3: audit-heal.sh

cleanup (end of batch). Each stage catches broken articles that got in through a different route. With only stage 1, short responses via test responses or network anomalies slip through. Layered defense looks like overkill, but once you've published a "Request timed out robot vacuum review" to production, you stop hesitating.

5. Cover affiliate link substitution with three variants

The assumption that "Claude's output will come back in exactly the format you specified" will always be betrayed. By preparing three stages of substitution that unify everything into hb.afl.rakuten.co.jp

no matter which format arrives, you reliably pass the final check in audit-heal.sh

.

6. Always sanitize macOS wc -l with | tr -d ' '

Make it a habit everywhere arithmetic expansion is involved. If you port to Linux in the future, the diff is a single spot. The cost is zero.

7. The Rakuten API needs three stages: "word-dropping retry → brand guard → hgc fallback"

What matters is not giving up on revenue even when you can't get an individual product link. The hgc fallback URL is a search-page link with affiliate tracking; conversion rate is lower than an individual product page, but it passes the hb.afl.rakuten.co.jp

check in audit-heal.sh

and prevents zero revenue. A design of "always insert some affiliate link and publish" achieves higher uptime than "throw the article away if you can't get a product link."

8. Use shopt -s nullglob with minimal scope

Wrap only around the for loop that uses the glob pattern. Applying it to the whole script silently turns intended globs elsewhere into empty arrays. Using it as a set with shopt -u nullglob

minimizes the blast radius of the side effect.

9. Absolutely separate the two logs by role

posted-products.log

holds product names (embedded into the prompt with paste -sd '、'

). posted-hatena.log

holds file paths (searched with grep -qxF

for exact matches). The formats differ, so merging them into one will definitely break one of the two use cases. Don't give in to the temptation to "simplify."

10. Specify --model sonnet explicitly to pin the cost

"$CLAUDE" -p "$PROMPT" --allowedTools WebSearch --model sonnet ...

Omit it and Claude Code's default model is used. When the default changes, or a config file changes, you can unintentionally switch to a higher-cost model. Pinning the cost per article lets you predict the monthly bill as "articles generated × cost per article."

11. Verify behavior without API calls using a test environment variable

if [ -n "${AFFILIATE_FACTORY_TEST_RESPONSE:-}" ]; then
  RESP="$AFFILIATE_FACTORY_TEST_RESPONSE"
fi

Checking postprocess_body

behavior or debugging Rakuten link substitution doesn't need a 260-second wait and API cost every time. Put AFFILIATE_FACTORY_TEST_RESPONSE="PRODUCT: テスト商品\n# タイトル..."

into the environment variable and you skip the claude call, verifying downstream processing instantly. This one trick dramatically shortens the development cycle.

12. Make binary PATHs absolute paths that can be overridden by environment variables

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

Write the default as an absolute path while making it overridable by environment variable. Even if your Mac dies and you reinstall claude, you handle it by changing one line in .env

. Hard-coding paths inside the script only troubles your future self.

"A system that publishes three affiliate articles every morning fully automatically" is, in the end, a design that moved every decision into four shell scripts.

何を書くか(商品選定)→ generate.sh + Claude
どう調べるか(スペック)→ WebSearch
どこに投稿するか      → post-to-hatena.sh + blogsync
ちゃんと動いているか  → audit-heal.sh + macOS通知

The cause of publishing zero articles in the first three days was "the 41-second gap between timeout 300 seconds and a measured 259 seconds." That's the sum of launchd startup overhead and morning WebSearch response delays — factors you would never notice with manual execution. "It worked locally = automation is done" is wrong; automation is complete only when you get the same result via launchd.

Most of the gotchas introduced here share the common pattern of "works locally, breaks only under launchd." The common thread in the countermeasures is "write with environment differences as a premise" (absolute paths, closed stdin, space sanitization, nullglob).

Idempotency, layered defense, audit logs. The combination of those three is the actual body of a system that keeps stacking up three articles a day without anyone touching it.

I've put the full picture of the system, the breakdown of the 1.2M yen per month, and the 30-day procedure into 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 #ai-agents 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/0-of-3-articles-publ…] indexed:0 read:31min 2026-08-15 ·