{"slug": "0-of-3-articles-published-for-3-days-straight-the-41-second-timeout-margin-that", "title": "0 of 3 Articles Published for 3 Days Straight: The 41-Second Timeout Margin That Killed My Automation", "summary": "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.", "body_md": "For three mornings in a row, my audit log printed the same line: `published today: 0 / target: 3`\n\n. 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.\n\nSome 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.\n\nThe 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**.\n\nArticles 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.\n\nThe 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.\n\nWhat you need here isn't \"trying harder\" — it's **an environment that keeps running even when you don't try hard**.\n\nOnce the system is built, the running cost is just API calls. The `affiliate-factory`\n\nI built is a simple structure made of four shell scripts. macOS `launchd`\n\n(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.\n\nThere's one more design-level core idea: **idempotency**.\n\nA 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.\n\n```\n# daily.sh\nPUB_TODAY=$(find \"$ARCHIVE\" -maxdepth 1 -name \"${TODAY}_*.md\" 2>/dev/null | wc -l | tr -d ' ')\nDRAFTS=$(find \"$OUT\" -maxdepth 1 -name '*.md' 2>/dev/null | wc -l | tr -d ' ')\nNEED=$((TARGET - PUB_TODAY - DRAFTS))\n[ \"$NEED\" -lt 0 ] && NEED=0\n```\n\nEven 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.\n\nI 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.\n\nHere's the structure of the whole system as an ASCII diagram.\n\n```\n[launchd] 毎朝・昼・夜の3回\n    │\n    ▼\n[daily.sh]  ← 司令塔。冪等に「今日あと何本必要か」を計算\n    │\n    ├─ NEED本分ループ ──────────────────────────────────────────┐\n    │                                                          │\n    │   [generate.sh]                                          │\n    │       │  claude -p + WebSearch で製品を選び記事を生成      │\n    │       │  timeout 600s / 最大3リトライ                     │\n    │       └──→ ~/Desktop/アフィリ記事/YYYY-MM-DD_HHMMSS.md ──┘\n    │\n    ├─ [post-to-hatena.sh --publish --all]\n    │       │  Desktop/*.md を blogsync ではてなブログへ全件公開\n    │       └──→ published/ へアーカイブ（Desktopキューから除去）\n    │\n    └─ [audit-heal.sh]\n            │  壊れ記事の掃除、カバレッジ表の出力、異常時はmacOS通知\n            └──→ logs/audit-YYYY-MM-DD.log\n```\n\nThe role of `daily.sh`\n\nis simple. Calculate what's left for today, call `generate.sh`\n\nonly as many times as needed, then run publishing and auditing in order. That's it.\n\n```\n# daily.sh（抜粋）\nTARGET=3\nTODAY=\"$(date +%Y-%m-%d)\"\n\nPUB_TODAY=$(find \"$ARCHIVE\" -maxdepth 1 -name \"${TODAY}_*.md\" 2>/dev/null | wc -l | tr -d ' ')\nDRAFTS=$(find \"$OUT\" -maxdepth 1 -name '*.md' 2>/dev/null | wc -l | tr -d ' ')\nNEED=$((TARGET - PUB_TODAY - DRAFTS))\n[ \"$NEED\" -lt 0 ] && NEED=0\n\nif [ \"$NEED\" -gt 0 ]; then\n  for i in $(seq 1 \"$NEED\"); do\n    bash \"$DIR/generate.sh\" \"$i\" || echo \"[daily] ⚠ 生成1本失敗（後続の再実行で補充されます）。\"\n  done\nfi\n\nbash \"$DIR/post-to-hatena.sh\" --publish --all\nbash \"$DIR/audit-heal.sh\"\n```\n\n`PUB_TODAY`\n\ncounts the files under `published/`\n\ncarrying today's prefix. `DRAFTS`\n\nis the number of drafts still sitting directly on the Desktop. `NEED`\n\nis the difference, clamped to 0 if it goes negative.\n\nThe important part is that processing doesn't stop when one `generate.sh`\n\nrun fails. `|| echo`\n\nswallows the error and a later batch refills the remainder. `set -uo pipefail`\n\nis 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.\n\n`generate.sh`\n\nis 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.\n\n**Prompt structure**\n\nThe prompt is defined in a heredoc inside `generate.sh`\n\n. It instructs Claude through the following steps.\n\nThe `EXCL`\n\nvariable at the top of the prompt holds the list of already-covered products. It's read from `posted-products.log`\n\nand handed to Claude as \"do not pick these products this time,\" so the same product doesn't come up repeatedly.\n\n**Invoking the claude command**\n\n```\n# generate.sh（抜粋）\nGEN_TIMEOUT=\"${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}\"\n\nfor attempt in 1 2 3; do\n  RESP=$(timeout \"$GEN_TIMEOUT\" \"$CLAUDE\" -p \"$PROMPT\" \\\n    --allowedTools WebSearch \\\n    --model sonnet \\\n    --permission-mode auto \\\n    </dev/null 2>/dev/null)\n  if resp_is_valid \"$RESP\"; then break; fi\n  echo \"[generate] 生成失敗(試行${attempt}/3)。再試行します…\" >&2\n  RESP=\"\"\ndone\n```\n\nEach of these three flags exists for a reason.\n\n** </dev/null** — a process launched from launchd has no tty. Without this,\n\n`claude -p`\n\nkeeps 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\n\n`auto`\n\nmakes the tools allowed via `--allowedTools`\n\nexecute 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.\n\nI cover the cause and the permanent fix in detail later in this article.\n\n**Generating Rakuten affiliate links**\n\nThe other important job of `generate.sh`\n\nis embedding Rakuten affiliate links correctly.\n\nThe \"search on Rakuten\" links Claude writes into the article don't carry affiliate tracking as-is. So the `postprocess_body`\n\nfunction post-processes the body in Python and replaces every Rakuten link Claude wrote.\n\n```\n# generate.sh（楽天リンク置換）\n# 2) claudeが本文に書いた実リンク [楽天で「…」を探す](任意URL) を、正しいアフィリリンクに丸ごと差し替える。\n#    （これをやらないと claude が書いた非アフィリの検索URLがそのまま残る＝報酬ゼロになる）\nrakuten_md_link_re = re.compile(r\"\\[楽天で「[^」]*」を探す\\]\\([^)]*\\)\")\nbody = rakuten_md_link_re.sub(lambda m: link, body)\n```\n\nThe links themselves are retrieved via Rakuten's Ichiba Item Search API. However, the Rakuten API has a `keyword is not valid`\n\nerror, and passing a product name like \"Narwal Freo Z Ultra\" verbatim can get rejected. As a countermeasure, the `resolve`\n\nfunction retries while dropping trailing words one at a time.\n\n``` python\n# generate.sh（楽天API語削り再試行ロジック）\ndef resolve():\n    words = product.split()\n    brand = words[0].lower() if words else \"\"\n    tried = set()\n    for n in range(len(words), 0, -1):\n        keyword = \" \".join(words[:n]).strip()\n        if not keyword or keyword in tried:\n            continue\n        tried.add(keyword)\n        result = fetch(keyword)\n        if result:\n            if not brand or brand in (result[\"name\"] + \" \" + result[\"url\"]).lower():\n                return result[\"url\"]\n            # ブランド不一致=別商品に化けた。打ち切り。\n            return None\n        time.sleep(1.0)\n    return None\n```\n\nIt 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.\n\nPosting the article drafts to Hatena Blog is the job of `post-to-hatena.sh`\n\n. The backend is `blogsync`\n\n(a Hatena Blog CLI written in Go).\n\n```\n# post-to-hatena.sh（post_one関数）\npost_one() {\n  local file=\"$1\"\n  local title body\n  title=\"$(sed -n 's/^# //p' \"$file\" | head -1)\"\n  [ -z \"$title\" ] && title=\"$(basename \"$file\" .md)\"\n  body=\"$(awk 'NR==1 && /^# /{next} {print}' \"$file\")\"\n\n  echo \"[hatena] 投稿: ${DRAFT_FLAG:-公開} | $title\"\n  printf '%s\\n' \"$body\" | \"$BLOGSYNC\" post $DRAFT_FLAG --title \"$title\" \"$BLOG\"\n}\n```\n\nIt extracts the first line of the Markdown (`# Title`\n\n) as the entry title and posts the body with the H1 removed. If the H1 stays in the body, Hatena renders it twice.\n\nRun with the `--publish --all`\n\nflags, it publishes every draft on the Desktop in one shot and moves published files into the `published/`\n\ndirectory, 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.\n\nPosted paths are recorded in `posted-hatena.log`\n\nand used for skip decisions during `--all`\n\nruns. Files listed in the log are excluded from posting, so the same article is never double-posted.\n\nLeftovers from failed generation (files whose body contains \"不明な商品\" or \"Request timed out\") are also detected and skipped before posting.\n\n```\nif /usr/bin/grep -qE '不明な商品|Request timed out' \"$f\"; then\n  echo \"[hatena] スキップ(生成失敗の残骸): $f\" >&2; continue\nfi\n```\n\nThis is the safety valve that prevents posting a failure file when Claude couldn't produce a decent article even after three attempts.\n\nThe last step is `audit-heal.sh`\n\n. It runs after the day's publishing finishes and does three things.\n\n**1) Cleaning up broken articles**\n\nIt deletes from the queue the failed-generation leftovers that `post-to-hatena.sh`\n\nskipped.\n\n```\nfor f in \"$OUT\"/*.md; do\n  [ -e \"$f\" ] || continue\n  if /usr/bin/grep -qE '不明な商品|Request timed out' \"$f\"; then\n    log \"  [掃除] 壊れ記事を削除: $(basename \"$f\")\"\n    rm -f \"$f\"\n  fi\ndone\n```\n\n**2) Printing a coverage table**\n\nFor every article published today, it verifies that affiliate links are properly included.\n\n```\nfor f in \"$ARCHIVE/${TODAY}\"_*.md; do\n  published=$((published+1))\n  title=\"$(sed -n 's/^# //p' \"$f\" | head -1 | cut -c1-30)\"\n  if /usr/bin/grep -q 'hb.afl.rakuten.co.jp' \"$f\"; then\n    link=\"✓アフィリ\"\n  else\n    link=\"✗非アフィリ\"; bad_link=$((bad_link+1)); problems=$((problems+1))\n  fi\n  log \"    ${link} | ${title}\"\ndone\n```\n\nThe decision is made on whether `hb.afl.rakuten.co.jp`\n\nappears 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.\n\n**3) macOS notifications**\n\nIf the target count wasn't reached, or if there are articles with non-affiliate links, it fires an alert into macOS Notification Center.\n\n```\nnotify() {\n  command -v osascript >/dev/null 2>&1 && \\\n  osascript -e \"display notification \\\"$1\\\" with title \\\"アフィリ自動投稿\\\"\" >/dev/null 2>&1 || true\n}\n```\n\nWhen a notification arrives, I check `logs/audit-YYYY-MM-DD.log`\n\n. Which article had the problem and how many were published are both recorded there.\n\nThe overall audit summary is printed in this format.\n\n```\n==== アフィリ監査 2026-06-22 07:15 ====\n  --- 本日公開分のカバレッジ ---\n    ✓アフィリ | Roborock S8 MaxV Ultra レビュー...\n    ✓アフィリ | Anker SOLIX C800 ポータブル電源...\n    ✓アフィリ | Narwal Freo Z Ultra 実機スペック...\n  --- サマリ ---\n  本日公開: 3本 / 目標: 3本 ｜ 未公開キュー残: 0本 ｜ 非アフィリ: 0本\n  ✅ 監査OK: 3本すべてアフィリリンク付きで公開\n```\n\nThis accumulates in `logs/`\n\nevery morning around 7am. The more these logs pile up, the more trust builds that the system is actually running.\n\nLater in this article I show, with real code, why `audit-heal.sh`\n\nkept recording \"published: 0 / target: 3\" for the first three days, and how changing one number fixed it for good.\n\n`resp_is_valid`\n\nprotects\nThe `resp_is_valid`\n\nfunction at the core of `generate.sh`\n\nis a short four-line implementation, but without it, broken articles pile up on the Desktop.\n\n```\nresp_is_valid() {\n  local r=\"$1\"\n  [ -z \"$r\" ] && return 1\n  printf '%s' \"$r\" | grep -q '^PRODUCT:' || return 1\n  printf '%s' \"$r\" | grep -qiE 'request timed out|error:|rate limit|usage limit' && return 1\n  [ \"$(printf '%s' \"$r\" | wc -c | tr -d ' ')\" -lt 400 ] && return 1\n  return 0\n}\n```\n\nThere are three conditions.\n\n**① 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\n\n`PRODUCT: <product name>`\n\n,\" Claude sometimes adds a preamble, or an API limit returns nothing but a message. Writing out a response with no `PRODUCT:`\n\nas an article appends the product name as `\"不明な商品\"`\n\n(unknown product) to `posted-products.log`\n\n, 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`\n\ncan 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`\n\nskips it via the `grep -qE '不明な商品|Request timed out'`\n\nguard, and it stays in the Desktop queue until `audit-heal.sh`\n\ncleans it. The guards are layered, but rejecting it in `resp_is_valid`\n\nis the root-level fix.\n\n**③ 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.\n\nOnly when all three conditions are met does it `break`\n\nand move to the next step; if even one fails, `RESP=\"\"`\n\nis set and the `attempt`\n\ncounter advances. When all three attempts fail, `generate.sh`\n\nexits 1 without writing a broken article.\n\n`PRODUCT:`\n\nline as an I/O contract\nThe 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?\n\n```\nPRODUCT=$(printf '%s\\n' \"$RESP\" | sed -n 's/^PRODUCT: *//p' | head -1)\nBODY=$(printf '%s\\n' \"$RESP\" | awk 'p{print} /^PRODUCT:/{p=1}')\n[ -z \"$BODY\" ] && BODY=\"$RESP\"\n[ -z \"$PRODUCT\" ] && PRODUCT=\"不明な商品\"\n```\n\nThe `PRODUCT:`\n\nline 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:`\n\nmarker, `awk`\n\nraises a flag (`p=1`\n\n) when it hits the `PRODUCT:`\n\nline and extracts only the lines after it as the body.\n\nThe extracted `PRODUCT`\n\ndoes three jobs: it's passed as the keyword to the Rakuten API, appended to `posted-products.log`\n\nto 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.\n\n`postprocess_body`\n\nperforms\nThe post-processing function `postprocess_body`\n\nin `generate.sh`\n\nembeds a bit over 30 lines of Python. This is the trickiest part, and it does more than it looks like.\n\nFirst it forcibly normalizes the Markdown structure.\n\n```\nlines = body.splitlines()\ntitle_idx = next((i for i, line in enumerate(lines) if line.startswith(\"# \")), None)\ntitle = lines.pop(title_idx) if title_idx is not None else f\"# {product} レビュー\"\n\nlines = [line for line in lines if line.strip() not in {disclaimer, contents}]\n```\n\nNo matter which line Claude puts the H1 on, `pop`\n\nlifts it out. If the disclosure text (`> ※本記事はアフィリエイト…`\n\n) or the table-of-contents tag (`[:contents]`\n\n) got included twice, they're removed. Finally `out = [title, disclaimer, contents]`\n\nfixes the first three lines. Whatever layout the body comes back in, the output always starts with \"H1 title → disclosure → TOC tag.\"\n\nThe next three substitutions are the main event.\n\n**Substitution ①: replacing placeholders**\n\n```\nplaceholder_re = re.compile(r\"（?▼?楽天で「[^」]+」を検索してリンク(?:を作成し、ここに貼る|を貼る)）?\")\nbody = placeholder_re.sub(link, body)\n```\n\nClaude sometimes writes the link as \"a placeholder a human should fill in later,\" in the form `（▼楽天で「Roborock S8」を検索してリンクを貼る）`\n\n. This picks that up and swaps in the correct affiliate link.\n\n**Substitution ②: replacing Rakuten Markdown links (most important)**\n\n```\nrakuten_md_link_re = re.compile(r\"\\[楽天で「[^」]*」を探す\\]\\([^)]*\\)\")\nbody = rakuten_md_link_re.sub(lambda m: link, body)\n```\n\nWhen the prompt says \"write it in the format `[楽天で「<product name>」を探す](<URL>)`\n\n,\" Claude follows the format, but the URL ends up being Rakuten's ordinary search page URL (`https://search.rakuten.co.jp/search/mall/...`\n\n). As-is, that's a non-affiliate link with no tracking. This regex overwrites every Markdown link beginning with `[楽天で「…」を探す]`\n\nwith the correct affiliate URL. It's the most important substitution — the `hb.afl.rakuten.co.jp`\n\ncheck in `audit-heal.sh`\n\npasses because this works correctly.\n\n**Substitution ③: a catch-all replacement for Rakuten-domain links**\n\n```\nrakuten_any_re = re.compile(r\"\\[[^\\]]+\\]\\((?:https?:)?//[^)]*rakuten\\.co\\.jp[^)]*\\)\")\nbody = rakuten_any_re.sub(lambda m: link, body)\n```\n\nThis 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`\n\ngets unified into the affiliate link.\n\nThis system has two deduplication logs.\n\n** posted-products.log** — the file that accumulates generated product names.\n\n```\nEXCL=$(paste -sd '、' \"$LOG\" 2>/dev/null)\n[ -z \"$EXCL\" ] && EXCL=\"（まだ無し）\"\n```\n\n`paste -sd '、'`\n\njoins 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.\n\n** posted-hatena.log** — the file that accumulates file paths already posted to Hatena.\n\n```\nif /usr/bin/grep -qxF \"$f\" \"$POSTED_LOG\"; then continue; fi\n```\n\nIt combines `-x`\n\n(whole-line match) and `-F`\n\n(fixed string). `daily.sh`\n\nruns three times a day and calls `post-to-hatena.sh --all`\n\nevery time. Without this log, the same file would be posted three times. `-F`\n\nis specified so that dots and slashes in file paths aren't interpreted as regex.\n\nFor the first three days, the `audit-heal.sh`\n\nlog looked like this.\n\n```\n==== アフィリ監査 2026-06-03 07:15 ====\n  --- 本日公開分のカバレッジ ---\n  --- サマリ ---\n  本日公開: 0本 / 目標: 3本 ｜ 未公開キュー残: 0本 ｜ 非アフィリ: 0本\n  ⚠ 公開が目標未達（生成 or 公開が失敗した可能性）\n  ❌ 監査NG: 要確認 (1件)\n```\n\nZero for three days straight. It should have been computing `NEED`\n\nand calling `generate.sh`\n\n, yet not a single draft had been created on the Desktop.\n\nAt 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`\n\nsettings. No change.\n\nOn the night of the third day, I ran `daily.sh`\n\ndirectly from the terminal. Thirty minutes later, one article was finished. **It was failing only via launchd.**\n\nAs I dug into the difference between launchd and manual execution, the comment left in `generate.sh`\n\nafter the fix caught my eye.\n\n```\n# フル記事生成(WebSearch複数回込み)は実測で約260sかかる。300sだとlaunchd下で僅かに超えて\n# timeoutにkillされ、全試行が空応答→0本公開になっていた。実測の2倍強を確保する。\nGEN_TIMEOUT=\"${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}\"\n```\n\n**Measured 259 seconds → timeout 300 seconds. The margin was only 41 seconds.**\n\nThe 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`\n\nkills it, it moves on to the next attempt with `RESP=\"\"`\n\n, and after three empty responses it exits 1. That went on for three days.\n\nThe fix was just changing the value of `GEN_TIMEOUT`\n\nfrom `300`\n\nto `600`\n\n. The next morning's log looked like this.\n\n```\n==== アフィリ監査 2026-06-06 07:31 ====\n    ✓アフィリ | Roborock S8 MaxV Ultra レビュー...\n    ✓アフィリ | Anker SOLIX C800 ポータブル電源...\n    ✓アフィリ | Narwal Freo Z Ultra 実機スペック...\n  本日公開: 3本 / 目標: 3本 ｜ 未公開キュー残: 0本 ｜ 非アフィリ: 0本\n  ✅ 監査OK: 3本すべてアフィリリンク付きで公開\n```\n\nConfirming 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`\n\nas an environment variable, so it can be adjusted without touching code.\n\nThere's also a reflection on \"why I didn't notice for three days.\" When `generate.sh`\n\nreturns exit 1, `daily.sh`\n\nabsorbs the error with `|| echo`\n\nand continues.\n\n```\nbash \"$DIR/generate.sh\" \"$i\" || echo \"[daily] ⚠ 生成1本失敗（後続の再実行で補充されます）。\"\n```\n\nThat 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`\n\nfires 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).\n\n`keyword is not valid`\n\nA 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`\n\n(couldn't get individual product link, falling back to search link).\n\nPassing `keyword: \"Narwal Freo Z Ultra\"`\n\nverbatim to the Rakuten Ichiba API returns HTTP status 400. The error body contains the string `keyword is not valid`\n\n.\n\n```\nexcept HTTPError as exc:\n    body = exc.read().decode(\"utf-8\", \"replace\")\n    if exc.code == 400 and \"keyword is not valid\" in body:\n        return None  # → resolve()の語削りループへ\n```\n\nThat `return None`\n\nis 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.\n\nSearching \"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.\n\n```\nif not brand or brand in (result[\"name\"] + \" \" + result[\"url\"]).lower():\n    return result[\"url\"]\n# ブランド不一致の判定（ブランド名は合っているが商品名が別物の場合）\nprint(f\"[generate] 候補がブランド不一致({keyword}→{result['name'][:30]})。\", file=sys.stderr)\nreturn None\n```\n\nThe 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.\n\nOne 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\n\n`generate.sh`\n\nis mandatory.With the first `launchd`\n\nconfiguration, `generate.sh`\n\nnever finished no matter how many minutes I waited after the batch started. The claude process existed in the process tree, but nothing was happening.\n\n```\nps aux | grep claude\n# → /Users/xxx/.local/bin/claude -p \"...\" --allowedTools WebSearch --model sonnet\n```\n\nThe claude process is definitely there. But it isn't running.\n\nIn 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`\n\n, so `claude -p`\n\ndecides \"no interactive input needed\" and proceeds. Under launchd there's no `/dev/tty`\n\n, so it entered a mode of waiting for something to arrive on stdin.\n\n```\nRESP=$(timeout \"$GEN_TIMEOUT\" \"$CLAUDE\" -p \"$PROMPT\" \\\n  --allowedTools WebSearch \\\n  --model sonnet \\\n  --permission-mode auto \\\n  </dev/null 2>/dev/null)\n```\n\n`</dev/null`\n\nimmediately makes stdin EOF. Adding that one thing stopped the hang.\n\n`--permission-mode auto`\n\nwas 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`\n\nthere's no response to that prompt and it hangs somewhere else. Specifying `auto`\n\nmakes the WebSearch explicitly listed in `--allowedTools`\n\nexecute without confirmation.\n\n**These two flags only work as a set.** With only `</dev/null`\n\n, it hangs at the permission prompt. With only `--permission-mode auto`\n\n, 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.\n\nNext 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.\n\nThe 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`\n\n. 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.\n\n**macOS wc -l outputs leading spaces**\n\nThis is one of the causes that breaks the idempotency calculation in `daily.sh`\n\n.\n\n```\nPUB_TODAY=$(find \"$ARCHIVE\" -maxdepth 1 -name \"${TODAY}_*.md\" 2>/dev/null | wc -l | tr -d ' ')\n```\n\nmacOS `wc -l`\n\noutputs with leading spaces, like `\" 3\"`\n\n. Without `tr -d ' '`\n\n, you get `NEED=$((TARGET - \" 3\" - DRAFTS))`\n\nand bash's arithmetic expansion returns an error. GNU Linux's `wc`\n\nhas 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.\n\n**Without shopt -s nullglob, the glob remains as a literal string**\n\nIn the today's-publications loop in `audit-heal.sh`\n\n, when there are no files for that day:\n\n```\nshopt -s nullglob\nfor f in \"$ARCHIVE/${TODAY}\"_*.md; do\n  published=$((published+1))\ndone\nshopt -u nullglob\n```\n\nWithout `shopt -s nullglob`\n\n, when the glob matches nothing, the pattern string `\"$ARCHIVE/2026-06-06_*.md\"`\n\nitself enters the loop. You can check existence with `[ -e \"$f\" ]`\n\n, but the implementation can end up incrementing the `published`\n\ncounter by 1 unintentionally. Always restore scope with `shopt -u nullglob`\n\nwhen you're done. Applying it globally silently empties other globs.\n\n**The -x and -F in grep -qxF come as a set**\n\n```\nif /usr/bin/grep -qxF \"$f\" \"$POSTED_LOG\"; then continue; fi\n```\n\nWithout `-x`\n\n(whole-line match), `/Desktop/アフィリ記事/2026-06-06_071532.md`\n\nfalsely matches as a substring for `071532.md`\n\n. Without `-F`\n\n(fixed string), the `.`\n\nin 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.\n\n**launchd's PATH is a different thing from the terminal's**\n\n```\nCLAUDE=\"${CLAUDE:-~/.local/bin/claude}\"\nBLOGSYNC=\"${BLOGSYNC:-$HOME/.local/bin/blogsync}\"\n```\n\nThe `PATH`\n\nof a launchd-started process is roughly `/usr/bin:/bin:/usr/sbin:/sbin`\n\n. It doesn't include anything under nvm (`~/.nvm/versions/node/vXX/bin/`\n\n) or `~/.local/bin/`\n\n. Even if `which claude`\n\nresolves in your terminal, via launchd the generation phase is wiped out by `command not found`\n\n. Writing defaults as absolute paths and overriding environment differences via `.env`\n\nor `EnvironmentVariables`\n\nis the most reliable design.\n\n**Drop --publish and drafts pile up on the Desktop, making NEED permanently 0**\n\n```\n# post-to-hatena.sh\n[ -z \"$DRAFT_FLAG\" ] && mv \"$f\" \"$ARCHIVE/\" && echo \"[hatena] アーカイブへ移動: $(basename \"$f\")\"\n```\n\nThe `mv`\n\nto the archive only happens with `--publish`\n\n. With `--draft`\n\n(the default), files stay on the Desktop forever. If a misconfiguration drops `--publish`\n\n, the `DRAFTS`\n\ncount keeps piling up on subsequent days and `NEED`\n\nis always calculated as 0. Every day it decides \"0 articles need generating,\" nothing happens, and only `audit-heal.sh`\n\nkeeps firing \"target not met\" notifications. The nasty part is that the symptom is indistinguishable from a timeout failure.\n\n**Without minPrice: \"30000\", replacement parts become the link target**\n\n```\n\"minPrice\": \"30000\",\n\"NGKeyword\": \"中古 訳あり 美品\",\n```\n\nSearching for \"Roborock S8 MaxV Ultra\" without `minPrice`\n\ncan 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\"`\n\neasily produces zero hits for out-of-stock or older models, which increases the frequency of hgc fallbacks.\n\n`2>/dev/null`\n\nmakes claude's error messages disappear entirely\n\n```\nRESP=$(timeout \"$GEN_TIMEOUT\" \"$CLAUDE\" -p \"$PROMPT\" ... 2>/dev/null)\n```\n\nIt'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`\n\nto see what's actually happening. Be sure to change it back to `2>/dev/null`\n\nwhen returning to production. Since the check in `resp_is_valid`\n\nis the only error-detection mechanism, error content has to be judged from the contents of the response string.\n\n**A test response without the PRODUCT: separator pollutes the product name**\n\n```\nPRODUCT=$(printf '%s\\n' \"$RESP\" | sed -n 's/^PRODUCT: *//p' | head -1)\n[ -z \"$PRODUCT\" ] && PRODUCT=\"不明な商品\"\n[ \"$PRODUCT\" != \"不明な商品\" ] && echo \"$PRODUCT\" >> \"$LOG\"\n```\n\nWhen feeding a test response directly via `AFFILIATE_FACTORY_TEST_RESPONSE`\n\n, forgetting the `PRODUCT:`\n\nline makes the product name `\"不明な商品\"`\n\n. Appending to `posted-products.log`\n\nis skipped by the `!= \"不明な商品\"`\n\ncheck, but the keyword `\"不明な商品\"`\n\ngets passed to the Rakuten API, leading to `keyword is not valid`\n\n→ 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: テスト商品名`\n\n.\n\n**Without seconds in the filename timestamp, fast tests collide**\n\n```\nFNAME=\"$OUT/$(date +%Y-%m-%d_%H%M%S).md\"\n```\n\nIn production each article takes about 260 seconds, so there are no collisions at minute granularity. But with `AFFILIATE_FACTORY_TEST_RESPONSE`\n\n, three articles are generated in seconds when NEED=3. With `%H%M`\n\n(minute granularity), the second article within the same minute overwrites the first. Using `%H%M%S`\n\n(second granularity) prevents collisions even in fast tests.\n\n**The Rakuten API brand guard lets a different model of the same brand slip through**\n\n```\nif not brand or brand in (result[\"name\"] + \" \" + result[\"url\"]).lower():\n    return result[\"url\"]\n```\n\n`brand`\n\nis the first word of the product name (e.g. `\"narwal\"`\n\n). 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.\n\n**Getting the split between set -uo pipefail and || echo wrong stops everything**\n\n```\n# daily.sh\nset -uo pipefail\n# ...\nbash \"$DIR/generate.sh\" \"$i\" || echo \"[daily] ⚠ 生成1本失敗（後続の再実行で補充されます）。\"\n```\n\nThe `set -uo pipefail`\n\nat the top of `daily.sh`\n\nmakes the whole script fail-fast. On the other hand, the `generate.sh`\n\ncall absorbs errors with `|| echo`\n\n. 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`\n\nto `|| true`\n\nerases the failure message and leaves you unable to tell what happened from the logs.\n\nDesign principles distilled from more than three months of live operation.\n\n**1. Set timeouts to at least 2× the measured value, and externalize them as environment variables**\n\n```\nGEN_TIMEOUT=\"${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}\"\n```\n\nAgainst 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`\n\n. No need to touch code.\n\n**2. The launchd-specific two-piece set is \" </dev/null + --permission-mode auto\"**\n\nNeither one works alone. `</dev/null`\n\nalone hangs at the WebSearch permission prompt. `--permission-mode auto`\n\nalone hangs on stdin. This applies to every situation where you run a CLI in an environment with no interactive input (`gh`\n\n, `git commit`\n\n, and interactive-prompting tools in general).\n\n**3. Design the idempotent NEED calculation first**\n\n```\nNEED = TARGET - PUB_TODAY - DRAFTS\n```\n\nWithout 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.\"\n\n**4. Defend against broken articles in three stages**\n\nStage 1: `resp_is_valid`\n\n(right after generation) → Stage 2: `grep -qE '不明な商品|Request timed out'`\n\n(right before posting) → Stage 3: `audit-heal.sh`\n\ncleanup (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.\n\n**5. Cover affiliate link substitution with three variants**\n\n```\n# 置換① プレースホルダー（▼楽天で「…」を検索してリンクを貼る）\n# 置換② MDリンク [楽天で「…」を探す](任意URL)\n# 置換③ 楽天ドメイン全般 [任意テキスト](rakuten.co.jp/...)\n```\n\nThe 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`\n\nno matter which format arrives, you reliably pass the final check in `audit-heal.sh`\n\n.\n\n**6. Always sanitize macOS wc -l with | tr -d ' '**\n\nMake 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.\n\n**7. The Rakuten API needs three stages: \"word-dropping retry → brand guard → hgc fallback\"**\n\nWhat 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`\n\ncheck in `audit-heal.sh`\n\nand 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.\"\n\n**8. Use shopt -s nullglob with minimal scope**\n\nWrap 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`\n\nminimizes the blast radius of the side effect.\n\n**9. Absolutely separate the two logs by role**\n\n`posted-products.log`\n\nholds product names (embedded into the prompt with `paste -sd '、'`\n\n). `posted-hatena.log`\n\nholds file paths (searched with `grep -qxF`\n\nfor 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.\"\n\n**10. Specify --model sonnet explicitly to pin the cost**\n\n```\n\"$CLAUDE\" -p \"$PROMPT\" --allowedTools WebSearch --model sonnet ...\n```\n\nOmit 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.\"\n\n**11. Verify behavior without API calls using a test environment variable**\n\n```\nif [ -n \"${AFFILIATE_FACTORY_TEST_RESPONSE:-}\" ]; then\n  RESP=\"$AFFILIATE_FACTORY_TEST_RESPONSE\"\nfi\n```\n\nChecking `postprocess_body`\n\nbehavior or debugging Rakuten link substitution doesn't need a 260-second wait and API cost every time. Put `AFFILIATE_FACTORY_TEST_RESPONSE=\"PRODUCT: テスト商品\\n# タイトル...\"`\n\ninto the environment variable and you skip the claude call, verifying downstream processing instantly. This one trick dramatically shortens the development cycle.\n\n**12. Make binary PATHs absolute paths that can be overridden by environment variables**\n\n```\nCLAUDE=\"${CLAUDE:-~/.local/bin/claude}\"\n```\n\nWrite 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`\n\n. Hard-coding paths inside the script only troubles your future self.\n\n\"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.\n\n```\n何を書くか（商品選定）→ generate.sh + Claude\nどう調べるか（スペック）→ WebSearch\nどこに投稿するか      → post-to-hatena.sh + blogsync\nちゃんと動いているか  → audit-heal.sh + macOS通知\n```\n\nThe 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**.\n\nMost 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).\n\nIdempotency, 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.\n\nI'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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/0-of-3-articles-published-for-3-days-straight-the-41-second-timeout-margin-that", "canonical_source": "https://dev.to/bokuwalily/0-of-3-articles-published-for-3-days-straight-the-41-second-timeout-margin-that-killed-my-4n7n", "published_at": "2026-08-15 00:00:07+00:00", "updated_at": "2026-08-15 00:40:40.846390+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "artificial-intelligence"], "entities": ["Claude Code", "Hatena Blog", "Rakuten Affiliate", "macOS launchd"], "alternates": {"html": "https://wpnews.pro/news/0-of-3-articles-published-for-3-days-straight-the-41-second-timeout-margin-that", "markdown": "https://wpnews.pro/news/0-of-3-articles-published-for-3-days-straight-the-41-second-timeout-margin-that.md", "text": "https://wpnews.pro/news/0-of-3-articles-published-for-3-days-straight-the-41-second-timeout-margin-that.txt", "jsonld": "https://wpnews.pro/news/0-of-3-articles-published-for-3-days-straight-the-41-second-timeout-margin-that.jsonld"}}