0 of 3 Articles Published for 3 Days Straight: The 41-Second Timeout Margin That Killed My Automation
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. Why this system works 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. # daily.sh 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. The overall flow 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 daily.sh — the idempotent controller 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. # daily.sh(抜粋) 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 — mass-producing articles with claude -p 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. Use WebSearch to pick one highly reviewed appliance/gadget priced above 50,000 yen (robot vacuum, portable power station, heat-pump washer-dryer, projector, etc.) Use WebSearch to verify its specs and street price (unknown items must be written as "manufacturer spec / needs verification," which prevents fabrication) Generate the article in the specified format (H1 title, disclosure notice, table of contents, conclusion → spec table → strong points → weak points → comparison → Q&A → summary) 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 # generate.sh(抜粋) 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) if resp_is_valid "$RESP"; then break; fi echo "[generate] 生成失敗(試行${attempt}/3)。再試行します…" >&2 RESP="" done Each of these three flags exists for a reason. ※本記事はアフィリエイト…) 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 [楽天で「」を探す]()," 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. Two logs that prevent the duplication trap 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. Where I got stuck Three days, zero articles published: timeout 300 vs. 259 measured 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. # フル記事生成(WebSearch複数回込み)は実測で約260sかかる。300sだとlaunchd下で僅かに超えて # timeoutにkillされ、全試行が空応答→0本公開になっていた。実測の2倍強を確保する。 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). The Rakuten API kept returning 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. The stdin hang that only happens under launchd 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 # → /Users/xxx/.local/bin/claude -p "..." --allowedTools WebSearch --model sonnet 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)
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to