0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

毎朝3本のアフィリ記事を完全自動で公開する仕組み (全2回の第1回):前編 ― 仕組みの全体像と、最初の3日間1本も公開できなかった話

0
Posted at

月10万の大学生が掛け持ちで月60万まで稼ぎ、会社都合の解雇で一夜にして0になり、半年でClaude Codeの自律環境を構築して今は月商120万を超えています。その中核にあるのが、毎朝3本のアフィリエイト記事を人間が何も触れずに公開し続ける仕組みです。

なぜこの仕組みが効くのか

アフィリエイトで稼ぎ続けている人と脱落する人の差は、文章力でも商品選定の嗅覚でもありません。継続できるかどうかだけです。

楽天アフィリエイトで収益が出やすい記事には共通パターンがあります。単価5万円以上の家電・ガジェットで、レビュー数が多く、在庫がある製品のスペック比較記事です。ロボット掃除機、ポータブル電源、ドラム式洗濯機、全自動コーヒーメーカー。検索意図が「買う前に比較したい」なので商品リンクのクリック率が高く、アフィリの構造と相性が良い。

問題はコストです。高単価家電のスペックをWebで調べ、比較表を作り、「正直イマイチな点」まで書ける程度の記事を1本仕上げると30〜40分かかります。3本なら2時間近い。それを365日繰り返す意志力を持つ人はほぼいません。私もいません。

ここで必要なのは「頑張る」ことではなく、頑張らなくても動き続ける環境です。

一度仕組みを作ればランニングコストはAPIコールだけになります。私が作った affiliate-factory はシェルスクリプト4本から成る単純な構造です。macOSの launchd(cronの後継)が毎朝・昼・夜の3回起動し、人間が何もしなくても毎日3本のアフィリエイト記事がはてなブログに公開されます。

もうひとつ、設計上の核心があります。冪等性です。

単純なスクリプトは今日すでに何本公開したかを気にしません。朝のバッチが失敗すれば、その日は0本のままです。この仕組みはまず「今日すでに公開できた本数」と「Desktop上のドラフト残数」を数え、目標3本に対して不足している本数だけを生成します。

# 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

午前中のバッチがAPI制限で全滅しても、昼のバッチが「今日あと3本必要」と計算して補充します。夕方のバッチが1本残りを埋めます。何度実行しても、その日の公開数は最終的に3本に収束する。この設計を理解してから読むと、4本のスクリプトの役割がまったく違って見えます。

読者の多くは「毎日記事を書かなければいけないのがしんどい」という状況にいると思います。私もそうでした。でも正確に言うと、しんどいのは「毎日記事を書く意思決定をすること」です。仕組みが決定を肩代わりするとき、人間はただ公開済みの記事を見るだけになります。

全体の流れ

システム全体の構造をアスキー図で示します。

[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 ─ 冪等な司令塔

daily.sh の役割はシンプルです。今日の残量を計算し、必要な分だけ generate.sh を呼び、公開処理と監査を順番に実行する。それだけです。

# 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_TODAYpublished/ 配下の本日プレフィックスのファイル数を数えます。DRAFTS はDesktop直下に残っているドラフト数です。NEED はその差分。マイナスになったら0にクランプします。

重要なのは generate.sh が1本失敗しても処理が止まらないことです。|| echo でエラーを飲み込み、後続のバッチが残りを補充します。set -uo pipefail を冒頭で宣言しつつ、個別の生成失敗はループ内で吸収する。全体は止めず、でも実行結果の追跡は失わない設計です。

generate.sh ─ claude -p で記事を量産する

generate.sh がこのシステムの心臓部です。WebSearchを使いながら、楽天で売れている高単価家電を自分で選び、スペックを調べ、記事を書く。それをClaudeが一人でやります。

プロンプトの構造

プロンプトは generate.sh 内のヒアドキュメントで定義されています。以下の手順をClaudeに指示します。

  1. WebSearchで単価5万円以上の高レビュー家電/ガジェットを1つ選ぶ(ロボット掃除機・ポータブル電源・ドラム式洗濯機・プロジェクターなど)
  2. WebSearchでそのスペックと実勢価格を確認する(不明項目は「公称値・要確認」と書かせ、捏造を防ぐ)
  3. 指定フォーマットで記事を生成する(H1タイトル、免責表示、目次、結論→スペック表→評価点→弱点→比較→Q&A→まとめ)

プロンプト冒頭の EXCL 変数には既出商品のリストが入ります。posted-products.log から読み込んで「これらの製品は今回選ばない」とClaudeに渡すことで、同じ製品が繰り返し登場しません。

claudeコマンドの呼び出し

# 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 2>/dev/null)
  if resp_is_valid "$RESP"; then break; fi
  echo "[generate] 生成失敗(試行${attempt}/3)。再試行します…" >&2
  RESP=""
done

3つのフラグにそれぞれ理由があります。

</dev/null ─ launchdから起動されたプロセスにはttyがありません。これがないと claude -p がstdinを待ち続けて止まります。ターミナルから手動実行するときは気づかない罠で、launchd下だけで再現します。

--permission-mode auto ─ WebSearchを使うとき、通常はClaudeが「WebSearchを使ってよいか」と確認を求めます。ttyがない環境でこれが出ると、プロンプトが返ってこないままハングします。auto を指定することで、--allowedTools で許可したツールは確認なしに実行されます。

timeout 600 ─ ここが本題です。WebSearchを複数回実行しながら記事を生成するフローは、実測で約260秒かかります。最初は300秒に設定していました。そして最初の3日間、1本も公開できませんでした。

その原因と根治については後編で詳しく書きます。

楽天アフィリエイトリンクの生成

generate.sh のもうひとつの重要な仕事が、楽天のアフィリエイトリンクを正しく埋め込むことです。

Claudeが記事中に書く「楽天で探す」リンクは、そのままではアフィリエイト計測が乗りません。そのため postprocess_body 関数がPythonで本文を後処理し、Claudeが書いたすべての楽天リンクを差し替えます。

# generate.sh(楽天リンク置換)
# 2) claudeが本文に書いた実リンク [楽天で「…」を探す](任意URL) を、正しいアフィリリンクに丸ごと差し替える。
#    (これをやらないと claude が書いた非アフィリの検索URLがそのまま残る=報酬ゼロになる)
rakuten_md_link_re = re.compile(r"\[楽天で「[^」]*」を探す\]\([^)]*\)")
body = rakuten_md_link_re.sub(lambda m: link, body)

リンク自体は楽天のIchiba Item Search APIで取得します。ただし楽天APIには「keyword is not valid」エラーがあり、"Narwal Freo Z Ultra"のような商品名をそのまま渡すと弾かれることがあります。この対策として resolve 関数が末尾の語を1つずつ削りながら再試行します。

# generate.sh(楽天API語削り再試行ロジック)
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

"Narwal Freo Z Ultra" → "Narwal Freo Z" → "Narwal Freo" の順で試し、ヒットした商品名にブランド名("narwal")が含まれるかを検証します。別の商品に化けたと判断したら、アフィリ計測付き検索URL(hgcリンク)にフォールバックします。個別商品リンクが取れなくても、報酬の乗る検索リンクを必ず入れる設計です。

post-to-hatena.sh ─ ブログへ自動公開

記事ドラフトをはてなブログに投稿するのが post-to-hatena.sh の役割です。バックエンドには blogsync(Go製のはてなブログCLI)を使っています。

# post-to-hatena.sh(post_one関数)
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"
}

Markdownの1行目(# タイトル)をエントリのタイトルとして取り出し、本文からはH1を除いて投稿します。H1が本文に残るとはてな側で二重表示になるためです。

--publish --all フラグで実行すると、Desktop上のドラフトを全件一発公開し、公開済みファイルを published/ ディレクトリに移動してDesktopキューから除去します。翌日のバッチが「今日のドラフト残数」を数えるとき、昨日分が混入しないための設計です。

投稿済みのパスは posted-hatena.log に記録され、--all 実行時のスキップ判定に使われます。ログに載っているファイルは投稿対象から外れるため、同一記事の二重投稿が起きません。

また生成失敗の残骸(本文に「不明な商品」や「Request timed out」が含まれるファイル)は投稿前に検出してスキップします。

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

Claudeが3回試行してもまともな記事を作れなかった場合、その失敗ファイルを投稿してしまわないための安全弁です。

audit-heal.sh ─ 自己修復と品質保証

最後のステップが audit-heal.sh です。毎日の公開が終わった後に走り、3つのことをします。

1) 壊れ記事の掃除

post-to-hatena.sh がスキップした生成失敗の残骸をキューから削除します。

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) カバレッジ表の出力

本日公開した全記事について、アフィリリンクが正しく入っているかを検証します。

for f in "$ARCHIVE/${TODAY}"_*.md; do
  published=$((published+1))
  title="$(sed -n 's/^# //p' "$f" | head -1 | cut -c1-30)"
  if /usr/bin/grep -q 'hb.afl.rakuten.co.jp' "$f"; then
    link="✓アフィリ"
  else
    link="✗非アフィリ"; bad_link=$((bad_link+1)); problems=$((problems+1))
  fi
  log "    ${link} | ${title}"
done

hb.afl.rakuten.co.jp が本文に含まれているかで判定します。含まれていなければ「✗非アフィリ」として問題カウントに加算します。レビュー記事がアフィリリンクなしで公開されても読者には気づかれませんが、報酬がゼロになります。これが自動化の盲点のひとつです。

3) macOS通知

目標本数に達しなかった場合、または非アフィリリンクの記事があった場合、macOSの通知センターに異常を飛ばします。

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

通知が来たら logs/audit-YYYY-MM-DD.log を確認します。どの記事が問題だったか、本数が何本だったか、ログに残っています。

全体の監査サマリは以下のフォーマットで出力されます。

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

これが毎朝7時台に logs/ に蓄積されていく。このログが溜まるほど、仕組みが動いていることへの信頼が積み上がります。

後編では、最初の3日間この audit-heal.sh が「公開: 0本 / 目標: 3本」を記録し続けた原因と、1行の数字を変えて根治した話を実コードで書きます。

実装の詳細

resp_is_valid が守る3つの条件

generate.sh の中核にある resp_is_valid 関数は4行の短い実装ですが、ここが抜けると壊れ記事が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
}

条件は3つです。

PRODUCT: 行が存在する ─ 後段でsedが商品名を取り出すための目印です。プロンプトが「1行目に必ず PRODUCT: <商品名> とだけ書く」と指示していても、Claudeが前置きを付けたり、API制限でメッセージだけ返ってくることがあります。PRODUCT: が無い応答を記事として書き出すと、商品名が "不明な商品" のまま posted-products.log に追記され、翌日の重複回避リストを汚染します。「不明な商品のレビュー」というタイトルのはてな記事は洒落になりません。

②エラー文が含まれていないtimeout でkillされたClaudeは "Request timed out" を含む短い応答を返すことがあります。API制限なら "rate limit" "usage limit" が含まれます。これを記事として書き出すと、post-to-hatena.shgrep -qE '不明な商品|Request timed out' のガードでスキップし、audit-heal.sh が掃除するまでDesktopキューに残ります。ガードは多重になっていますが、resp_is_valid で弾いておくのが根本対策です。

③400文字以上ある ─ WebSearchが空振りして「該当製品が見つかりませんでした」的な数行だけが返ることがあります。記事1本分のMarkdownは最低でも1,500〜3,000文字あるため、400文字未満は明確な失敗です。この閾値は経験則で、短すぎて記事にならない応答をすべてキャッチできます。

この3条件を満たした場合だけ break して次のステップへ進み、1つでも外れたら RESP="" にして attempt カウンターを進めます。3回試行してすべて失敗したとき、generate.sh は壊れ記事を書かずに exit 1 します。

PRODUCT: 行という入出力の契約

プロンプトの最重要指示は「1行目に必ず PRODUCT: <正式商品名> とだけ書く」という縛りです。なぜ記事の冒頭にこの行が必要なのか。

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="不明な商品"

PRODUCT: 行は、Claudeの応答から「商品名」と「本文」を確実に分離するためのセパレーターです。「1行目がH1タイトル、2行目から本文」という構造で返させても、前置き文が混入することがあります。PRODUCT: という明示的なマーカーを設けることで、awkPRODUCT: 行に出会ったフラグ(p=1)を立て、それ以降の行だけを本文として取り出せます。

取り出した PRODUCT は3つの仕事をします。楽天APIへのキーワードとして渡す、posted-products.log に追記して翌日の重複回避リストに加える、そしてアフィリエイトリンクを生成するための商品名として使う。この1行があらゆる下流処理の起点になっています。

postprocess_body が行う3種の置換

generate.sh の後処理関数 postprocess_body には30行強のPythonが内包されています。ここが最もトリッキーな箇所で、見た目より複雑なことをしています。

まずMarkdownの構造を強制的に正規化します。

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}]

Claudeが本文中の何行目にH1を書いても、pop でいったん取り出します。免責文(> ※本記事はアフィリエイト…)と目次タグ([:contents])が二重に入っていれば除去します。最後に out = [title, disclaimer, contents] で先頭3行を確定させます。どんなレイアウトで本文が返ってきても、出力の冒頭は常に「H1タイトル→免責→目次タグ」に揃います。

次の3種の置換が本題です。

置換①:プレースホルダーの差し替え

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

Claudeがリンクを「後で人間が入れるべきプレースホルダー」として書くことがあります。(▼楽天で「Roborock S8」を検索してリンクを貼る) という形式です。これを拾って正しいアフィリリンクに差し替えます。

置換②:楽天MDリンクの差し替え(最重要)

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

プロンプトで「[楽天で「<商品名>」を探す](<URL>) の形式で書け」と指示すると、Claudeはその形式に従いますが、URLは楽天の通常検索ページURL(https://search.rakuten.co.jp/search/mall/...)になります。そのままでは計測が乗らない非アフィリリンクです。この正規表現が「[楽天で「…」を探す]」で始まるMarkdownリンクをすべて正しいアフィリURLに上書きします。これが最も重要な置換で、audit-heal.shhb.afl.rakuten.co.jp チェックが通るのはここが正しく機能しているからです。

置換③:楽天ドメインリンクの念押し差し替え

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

比較表の中のリンクやまとめセクションでClaudeが別の形式で楽天URLを書いた場合の最終防衛線です。rakuten.co.jp を含むMarkdownリンクはすべてアフィリリンクに統一します。

2本のログで防ぐ重複の罠

このシステムには重複防止ログが2本あります。

posted-products.log ─ 生成した商品名を追記するファイルです。

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

paste -sd '、' で全行を読点区切りの1行に結合し、プロンプトの除外セクションに埋め込みます。「Roborock S8 MaxV Ultra」が入っていれば、Claudeは同じ製品を翌日また選ぶことはありません。数ヶ月貯まれば「同じ記事が出る」問題は事実上なくなります。

posted-hatena.log ─ はてなへの投稿済みファイルパスを追記するファイルです。

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

-x(行全体マッチ)と -F(固定文字列)を組み合わせています。daily.sh が1日3回走り、post-to-hatena.sh --all が毎回呼ばれます。このログがなければ同一ファイルが3回投稿されます。-F はファイルパス中のドットやスラッシュを正規表現として解釈しないための指定です。


私が詰まった話

3日間で1本も公開できなかった:timeout 300 対 259

最初の3日間、audit-heal.sh のログはこうなっていました。

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

3日連続で0本。NEED を計算して generate.sh を呼び出しているはずなのに、Desktopにドラフトが1つも生成されていません。

最初は「プロンプトが悪い」と思いました。商品カテゴリを増やし、出力フォーマットの指示を詳細にしました。何も変わりませんでした。次に「ツール許可の問題か」と疑い、--allowedTools の設定を見直しました。変化なし。

3日目の夜、daily.sh をターミナルから直接実行してみました。30分後、記事が1本できあがっていました。launchd経由でのみ失敗していました

launchd経由と手動実行の差を調べていくと、generate.sh の中に修正後のコメントとして残っている記述が目に入ります。

# フル記事生成(WebSearch複数回込み)は実測で約260sかかる。300sだとlaunchd下で僅かに超えて
# timeoutにkillされ、全試行が空応答→0本公開になっていた。実測の2倍強を確保する。
GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}"

実測259秒 → timeout 300秒。余裕は41秒しかありませんでした。

WebSearchを3〜4回呼びながら記事を生成するフローは、ターミナルでの計測で約259秒かかります。300秒なら余裕があるように見えます。しかしlaunchd下のプロセスは起動オーバーヘッドがわずかに大きく、朝7時台のWebSearchレスポンスが若干遅い時間帯に重なると、259秒が305秒になることがある。timeout がkillすると RESP="" のまま次の試行へ進み、3回すべて空応答で exit 1。それが3日続いていました。

修正は GEN_TIMEOUT の値を 300 から 600 に変えるだけです。変えた翌朝のログはこうなりました。

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

手動で動くことを確認しただけでは不十分で、launchd経由で同じ結果になるまで確認することが自動化の完了条件です。この教訓から AFFILIATE_FACTORY_GEN_TIMEOUT を環境変数として外出しし、再コンパイルなしに調整できる設計にしました。

また「なぜ3日間気づけなかったか」の反省もあります。generate.shexit 1 を返したとき、daily.sh|| echo でエラーを吸収して処理を続けます。

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

この設計は「朝が失敗しても昼が補充する」という冪等性のための意図的な選択でした。しかし同時に、「毎回失敗していても処理が止まらず、macOS通知も上がらない」という盲点を生んでいました。audit-heal.sh が公開0本で通知を飛ばすのは処理の最後なので、launchd経由での通知が届いていませんでした(後で確認すると通知センターに溜まっていました)。

楽天APIが keyword is not valid を返し続けた

システムが安定稼働を始めて1週間後、Narwal Freo Z Ultraのロボット掃除機の記事が生成されたとき、[generate] 商品個別リンクを取得できず検索リンクにフォールバック: Narwal Freo Z Ultra というログが出続けました。

楽天IchibaのAPIに keyword: "Narwal Freo Z Ultra" をそのまま渡すと、HTTPステータス400が返ります。エラーボディに 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()の語削りループへ

return None が語削り再試行のトリガーです。"Narwal Freo Z Ultra" の "Z" という1文字トークンが問題でした。"Narwal Freo Z" でも弾かれます。"Narwal Freo" でようやく通りますが、そこで次の問題が起きます。

「Narwal Freo」でレビュー数降順に検索すると、現行の最人気モデルがヒットします。それが「Narwal Freo Ultra」だった場合、ブランド名「narwal」が商品名に含まれているためブランドガードを通過します。元のターゲットは"Narwal Freo Z Ultra"なのに、別モデルのリンクが張られてしまう。

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

現在の実装はブランド名(先頭の単語)でのフィルタに留まっています。「narwal」という文字列が結果に含まれているかを確認するため、同じブランドの別モデルはすり抜けます。hgcフォールバックURLはアフィリ計測が乗るため報酬ゼロにはなりませんが、個別商品リンクの精度は課題です。

この問題から得た仕様の知識が1つあります。楽天APIは単一文字のトークンを含むキーワードを keyword is not valid で弾きます。型番に多い「Z」「S」「X」「i」などが入る商品名は必ず引っかかります。generate.sh の語削りループは必須です。

launchd下だけで起きるstdinハング

最初の launchd 設定では、バッチが走り出してから何分待っても generate.sh が終わりませんでした。プロセスツリーには claudeプロセスが存在しているのに、何も起きていない。

ps aux | grep claude
# → /Users/xxx/.local/bin/claude -p "..." --allowedTools WebSearch --model sonnet

claudeプロセスが確かに存在しています。しかし動いていません。

ttyがないlaunchd環境で、claudeコマンドがstdinから入力を待っていたのです。ターミナルでは /dev/tty からユーザーの入力が受けられるため、claude -p は「対話入力不要」と判断して処理を進めます。launchd下では /dev/tty がなく、stdinから何かが来るまで待つモードに入っていました。

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

</dev/null でstdinを即座にEOFにする。この1行追加で止まらなくなりました。

同時に --permission-mode auto も必要でした。WebSearchを使うとき、Claudeが「WebSearchを使ってよいですか」と確認プロンプトを出します。ttyがある環境では人間が「yes」と入力できますが、</dev/null でstdinが閉じている状態ではこのプロンプトへの応答が返らず、別の場所でハングします。auto を指定することで、--allowedTools で明示したWebSearchは確認なしに実行されます。

この2つのフラグはセットで機能します。 </dev/null だけでは許可プロンプトでハングし、--permission-mode auto だけではstdinでハングします。どちらか片方だけでは解決しません。この2つが揃って初めて、launchd下でターミナルと同じ動作になります。

次回は、この仕組みが稼働し続けるために実装した自己修復canary(正常系テスト・意図的失敗テスト)と、3ヶ月分のログから読み取れた実際の稼働率・収益データを公開します。

つまずきポイント

前章ではtimeout 300秒が実測259秒に負けた件・launchd下のstdinハング・楽天API keyword is not valid の3大障害を解説しました。以下はそれ以外で実際に踏んだ細かい罠の一覧です。仕組みの「なぜこうなっているか」が見えてくるので、自前で作るときの設計参考にもなります。


macOSの wc -l は先頭にスペースを出力する

daily.sh の冪等計算が壊れる原因の一つです。

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

macOSの wc -l" 3" のように先頭にスペース付きで出力します。tr -d ' ' がないと NEED=$((TARGET - " 3" - DRAFTS)) となり、bashの算術展開がエラーを返します。GNU Linuxの wc はスペースなしなので手元のLinux環境では一切気づかず、macOSのlaunchd環境で初めて症状が出ます。1行追加で完全に解消します。


shopt -s nullglob がないとglobがリテラル文字列として残る

audit-heal.sh の本日公開分ループで、当日ファイルが1本もない場合:

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

shopt -s nullglob がないとglobがマッチしないとき、パターン文字列 "$ARCHIVE/2026-06-06_*.md" そのものがループに入ります。[ -e "$f" ] で存在確認はできますが、published カウンターが意図せず1加算される実装になりえます。使い終わったら shopt -u nullglob で必ずスコープを戻す。全体に効かせると別のglobが静かに空になります。


grep -qxF-x-F はセット

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

-x(行全体マッチ)がないと /Desktop/アフィリ記事/2026-06-06_071532.md071532.md の部分文字列として誤マッチします。-F(固定文字列)がないとパス中の . が正規表現の「任意文字」として解釈されます。どちらが欠けても大半のケースでは動くため単体テストでは気づきにくく、特定のファイル名のときだけ二重投稿が起きる間欠障害になります。


launchdの PATH はターミナルと別物

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

launchd起動プロセスの PATH/usr/bin:/bin:/usr/sbin:/sbin 程度です。nvm配下(~/.nvm/versions/node/vXX/bin/)や ~/.local/bin/ は含まれません。ターミナルで which claude が通っていても、launchd経由では command not found で生成フェーズが全滅します。デフォルト値を絶対パスで書き、環境差異は .envEnvironmentVariables で上書きする設計が最も確実です。


--publish を外すとDesktopにドラフトが積み上がって NEED が常に0になる

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

アーカイブへの mv--publish 時だけです。--draft(デフォルト)ではDesktopにファイルが残り続けます。設定ミスで --publish を外した場合、DRAFTS カウントが翌日以降も積み上がり、NEED が常に0と計算されます。毎日「生成0本必要」と判断されて何も起きず、audit-heal.sh だけが「目標未達」の通知を飛ばし続けます。症状がtimeout失敗と区別がつかない点が厄介です。


minPrice: "30000" がないと交換パーツがリンク先になる

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

minPrice なしで「Roborock S8 MaxV Ultra」を検索すると、交換用ダストパックや専用クリーナー液が高レビュー順の上位にくることがあります。3万円の下限で消耗品・アクセサリの誤ヒットを抑制しています。「単価5万以上」というプロンプト指示との差は、楽天での実売価格が定価より下がるケースへの余裕です。minPrice: "50000" にすると在庫切れや旧モデルが0件ヒットになりやすく、hgcフォールバック頻度が上がります。


2>/dev/null でclaudeのエラーメッセージが完全に消える

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

launchd環境でstderrのノイズを抑制するために付けていますが、副作用としてtimeout kill・認証エラー・モデルエラーのメッセージがすべて捨てられます。デバッグ時は一時的に 2>/tmp/claude-err.log に変えると実体が確認できます。本番に戻す際は必ず 2>/dev/null に戻すこと。resp_is_valid での判定が唯一のエラー検知手段になっているため、エラー内容は応答文字列の中身で判断します。


PRODUCT: セパレータがないテスト応答は商品名を汚染する

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

AFFILIATE_FACTORY_TEST_RESPONSE でテスト応答を直接流す場合、PRODUCT: 行を付け忘れると商品名が "不明な商品" になります。posted-products.log への追記は != "不明な商品" チェックでスキップされますが、楽天APIに "不明な商品" というキーワードが渡り keyword is not valid → hgcフォールバックの流れになります。意味不明な商品名を持つhgcリンク付き記事がDesktopに残ります。テスト用レスポンスの1行目は必ず PRODUCT: テスト商品名 にします。


ファイル名の秒タイムスタンプがないと高速テスト時に衝突する

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

本番は1記事あたり約260秒かかるので分単位で衝突しませんが、AFFILIATE_FACTORY_TEST_RESPONSE を使うとNEED=3の場合に3本が数秒で生成されます。%H%M(分単位)だと同一分内の2本目が1本目を上書きします。%H%M%S(秒単位)にすることで、高速テストでも衝突しません。


楽天APIのブランドガードは同ブランド別モデルをすり抜ける

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

brand は商品名の先頭1語(例: "narwal")です。「Narwal Freo Z Ultra」で語を削って「Narwal Freo」でヒットしたとき、最もレビュー数の多い「Narwal Freo Ultra」が返ることがあります。「narwal」という文字列はどちらの商品名にも含まれるためブランドガードを通過します。元のターゲットとは別モデルのリンクが埋め込まれますが、同ブランドのアフィリリンクなので報酬は乗ります。完全に防ぐにはモデル番号一致確認が必要で、現状はhgcフォールバックを最終安全弁として運用しています。


set -uo pipefail|| echo の使い分けを間違えると全体が止まる

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

daily.sh 冒頭の set -uo pipefail はスクリプト全体をfail-fastにします。一方で generate.sh の呼び出しは || echo でエラーを吸収しています。この使い分けが意図的な設計で、「全体のフロー(公開・監査)は止めたくないが、個別の生成失敗は許容する」を表現しています。|| echo|| true に変えると失敗メッセージが消え、ログを見ても何が起きたか分からなくなります。


ベストプラクティス

3ヶ月以上の実稼働で蒸留された設計原則です。

1. timeoutは実測値×2以上に設定し、環境変数で外出しする

GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-600}"

実測259秒に対して300秒では、launchd起動オーバーヘッドと朝の外部APIレスポンス遅延の合算で簡単に超えます。「実測値×2以上、かつ環境変数化」が鉄則です。.env の1行変更だけで再稼働できる。コードを触らなくていい。

2. launchd専用の2点セットは「</dev/null--permission-mode auto

どちらか片方では機能しません。</dev/null だけではWebSearch許可プロンプトでハングします。--permission-mode auto だけではstdinでハングします。対話入力できない環境でCLIを動かすすべての場面に応用できます(gh, git commit, その他対話を求めるツール全般)。

3. 冪等な NEED 計算を先に設計する

NEED = TARGET - PUB_TODAY - DRAFTS

「何回実行しても1日の公開数が収束する」という性質がなければ、1日3回のlaunchdバッチは二重公開または過不足の温床になります。冪等性は自動化の根幹です。失敗リカバリーも「次のバッチが自動で残りを埋める」という一文で完結します。

4. 壊れ記事は3段で防御する

1段目:resp_is_valid(生成直後)→ 2段目:grep -qE '不明な商品|Request timed out'(投稿直前)→ 3段目:audit-heal.sh 掃除(バッチ最後)。各段で別ルートから入り込んだ壊れ記事を捕まえます。1段目だけだと、テスト応答経由やネットワーク異常による短い応答が通り抜けます。多重防衛はやりすぎに見えますが、本番に「Request timed outのロボット掃除機レビュー」を公開した経験があれば迷いがなくなります。

5. アフィリリンク置換は3種で網羅する

# 置換① プレースホルダー(▼楽天で「…」を検索してリンクを貼る)
# 置換② MDリンク [楽天で「…」を探す](任意URL)
# 置換③ 楽天ドメイン全般 [任意テキスト](rakuten.co.jp/...)

Claudeのアウトプットが「指定した形式通りに来る」という前提は必ず裏切られます。どの形式で来ても hb.afl.rakuten.co.jp に統一する3段の置換を用意することで、audit-heal.sh の最終チェックを確実に通すことができます。

6. macOS wc -l| tr -d ' ' で必ずサニタイズする

算術展開が絡む箇所のすべてに付ける習慣にする。将来Linuxに移植したとき差分が1箇所で済みます。コストはゼロです。

7. 楽天APIは「語削り再試行 → ブランドガード → hgcフォールバック」の3段

商品個別リンクが取れなくても報酬を諦めないことが重要です。hgcフォールバックURLはアフィリ計測付き検索ページリンクで、個別商品ページより成約率は落ちますが、audit-heal.shhb.afl.rakuten.co.jp チェックを通過し、報酬ゼロを防ぎます。「商品リンクが取れないなら記事を捨てる」より「必ず何らかのアフィリリンクを入れて公開する」設計のほうが稼働率が高くなります。

8. shopt -s nullglob はスコープを最小化して使う

globパターンを使うforループの前後だけを囲む。スクリプト全体に適用すると、他の箇所で意図したglobが静かに空配列になります。shopt -u nullglob とセットで使うことで、副作用の射程を最小化できます。

9. 2本のログは役割で絶対に分ける

posted-products.log は商品名(paste -sd '、' でプロンプトに埋め込む)。posted-hatena.log はファイルパス(grep -qxF で完全一致検索)。形式が違うため1本にまとめると必ずどちらかの用途が壊れます。「シンプルにしたい」という誘惑に乗らないこと。

10. --model sonnet を明示してコストを固定する

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

省略するとClaude Codeのデフォルトモデルが使われます。デフォルトが変わったタイミングや設定ファイルの変更で意図せず高コストなモデルに切り替わります。1本あたりのコストを固定しておくと、月次で「生成本数 × 1本コスト」で請求を予測できます。

11. テスト環境変数でAPIコールなしに動作確認する

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

postprocess_body の挙動確認や楽天リンク置換のデバッグに、毎回260秒待ちとAPIコストは不要です。AFFILIATE_FACTORY_TEST_RESPONSE="PRODUCT: テスト商品\n# タイトル..." を環境変数に入れれば、claude呼び出しをスキップして後段処理を即時確認できます。この一工夫で開発サイクルが大幅に短縮されます。

12. bainaryのPATHは絶対パス+環境変数でオーバーライド可能にする

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

デフォルトは絶対パスで書きつつ、環境変数で上書き可能にする。Macが壊れてclaudeを再インストールした場合も .env の1行変更で対応できます。スクリプト内にパスをハードコードするだけでは将来の自分を困らせます。


まとめ

「毎朝3本のアフィリ記事を完全自動で公開する仕組み」は、結局のところ4本のシェルスクリプトに全判断を移した設計です。

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

最初の3日間で1本も公開できなかった原因は「timeout 300秒 vs 実測259秒の差41秒」でした。これはlaunchdの起動オーバーヘッドと朝のWebSearchレスポンス遅延という、手動実行では絶対に気づかない要因の合算です。「手元で動いた = 自動化完了」は誤りで、launchd経由で同じ結果が得られるまでが自動化の完了条件です。

今回紹介したつまずきポイントの大半は「手元で動くのにlaunchd下でだけ壊れる」という共通パターンを持ちます。対策の共通点は「環境差異を前提に書く(絶対パス・stdin閉じ・スペースサニタイズ・nullglob)」です。

冪等性・多重防衛・監査ログ。この3つの組み合わせが、誰も触れなくても毎日3本が積み上がり続ける仕組みの本体です。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています → 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?