作者详细解析了如何用 launchd + Claude Code 构建每日自动发文系统,包括幂等设计确保精确日产 5 篇、失败自动恢复、自愈脚本等工程细节。
每天早上 5 点,launchd 唤醒系统,等我醒来时,我从未写过的文章已经上线了。
在第一部分,我介绍了以 Claude Code 为核心的文章生成器的基本设计,以及 generate.sh 如何生成单篇草稿的框架。这次我要公开整个后半部分——也就是真正保证系统运行的部分:如何可靠地嵌入变现链接、当 Rakuten API 失败时如何退出、一种幂等设计使无论运行多少次都恰好收敛到每天 5 篇文章,以及建立在 audit-heal.sh 之上的自愈循环。
当我还是大学生、每月靠写内容赚 10 万日元副业时,我的收入公式很简单:时间 × 单价。我能把月收入提高到 60 万日元的原因是我同时接了多份工作、把工作时间压到极限——而不是因为我建立了一套系统。
当我被裁员时,收入立刻归零。一个卖时间的副业,一旦没有人可以出卖时间,就会瞬间崩溃。那一刻我明白了:构建一个能赚钱的环境和做能赚钱的工作,是两件完全不同的事。
在用 Claude Code 组装自主运行环境六个月后,我目前每月 120 万日元的收入,大部分是在我不工作的时间段积累的。这个_affiliate 工厂_就是其核心组件之一。
从_affiliate 文章_中获利的最大瓶颈在于写作本身。一篇 8000–10000 字的产品评测,至少需要 3–4 小时(包括调研)。每天保持 3–5 篇的产出,除非是全职工作,否则在体力上是不可能的。
大多数写手优化的方向是"我怎么能写得更快?"建立模板、用 AI 做调研、用语音输入——都有效,但都有一个可见的天花板——因为一天只有那么多小时。
从根本上改变方法,问题本身也会改变。不是"我怎么能写得更快?"而是"我怎样才能不再需要自己写?"这个系统就是我对这个问题正面的回答。
一句话概括:launchd 在早、中、晚三个时间点触发 daily.sh,只生成和发布今天还缺的文章数量。
关键点不在于我什么都不用做。更准确地说,设计本身就是如此——如果我参与其中,系统就会出问题。手动添加或删除文件会破坏幂等性。相信 launchd 的调度让它自己运行,把人的手放开。正是这种"放手"让系统保持稳定。
凌晨 5 点 launchd 触发 daily.sh。文章被生成并发布到 Hatena Blog,同时写入审计日志,如果出了问题,警告会发送到 macOS 通知中心。我 7 点醒来,只需检查那条通知。整个检查过程不超过三分钟。
幂等意味着"无论运行多少次,都产生相同的结果"。这在 daily.sh 的注释中写得清清楚楚。
# 1日複数回実行される自己回復ジョブ。「今日まだ公開できていない本数」だけを
# 生成→公開する冪等設計。朝が使用量制限等で空振りしても、昼/夜の再実行が
# 自動で残りを埋めるため、何度走らせても1日ちょうど TARGET 本で収束する。
这不是单纯的注释——而是设计的核心。即使早晨运行撞上 Claude Code 速率限制、生成了零篇文章,中午的运行也会补上缺口。如果中午也失败了,晚上会覆盖它。无论何时、无论运行多少次,到一天结束时都会收敛到目标数量。
没有这种设计,每次早晨运行失败都意味着"今天泡汤了"。有了它,部分失败会被系统自动吸收。
在 generate.sh 中调用 Claude 的核心是这一行(实际脚本的第 272 行)。
RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" --allowedTools WebSearch \
--model sonnet --permission-mode auto </dev/null 2>/dev/null)
</dev/null 关闭标准输入,--permission-mode auto 绕过 WebSearch 的权限提示,timeout "$GEN_TIMEOUT" 强制终止挂起的进程。注释写道:"</dev/null 是必需的:没有它,claude -p 会在 stdin 上等待 3 秒才继续(这在 launchd 环境下每次都会发生,因为没有 tty)。"这是 launchd 环境特有的一个坑。
GEN_TIMEOUT 可以通过环境变量覆盖,但默认值是 1200 秒(20 分钟)。因为它要生成一篇 8000–10000 字的文章,还要对三款竞争产品进行 WebSearch,没有足够的缓冲时间,进程会在文章生成到一半时被杀死,返回空响应,导致最坏的结果:零篇文章发布。看起来很贵,但与每篇文章的收入相比就不算什么了。
launchd (朝・昼・夜、1日複数回)
│
▼
daily.sh
│ NEED = TARGET - 本日公開済 - ドラフト残 ← 冪等計算
│ NEED=0 なら生成スキップ
│
├─ [NEED > 0] generate.sh × NEED 本
│ │
│ ├─ Claude Code claude -p (timeout 1200s, 最大3回リトライ)
│ │ └─ WebSearch で製品調査 + 競合3製品比較
│ │
│ ├─ resp_is_valid() バリデーション
│ │ └─ PRODUCT:行なし / エラー文含む / 400文字未満 → 失敗扱い
│ │
│ ├─ rakuten_affiliate_url() ← 楽天APIで商品リンク取得
│ │ ├─ 資格情報あり → API検索 (resolve: 末尾語削り戦略)
│ │ │ ├─ 命中 + ブランド一致 → affiliateUrl 取得
│ │ │ ├─ 400 "keyword is not valid" → 語を削って再挑戦
│ │ │ └─ 全滅 → hgc 検索リンクfallback (報酬乗る)
│ │ └─ 資格情報なし → 素の検索URL (報酬ゼロ注意)
│ │
│ ├─ postprocess_body(): 素リンク・プレースホルダー → アフィリリンク全差替
│ │
│ └─ ~/Desktop/アフィリ記事/<YYYYMMDD_HHMMSS>.md
│
├─ post-to-hatena.sh --publish --all
│ ├─ posted-hatena.log でスキップ判定 (冪等)
│ ├─ 壊れ記事 (不明な商品 / Request timed out) スキップ
│ ├─ blogsync post --title "$title" bokuwalily.hatenablog.com
│ └─ 公開済み → published/ にアーカイブ移動
│
└─ audit-heal.sh
├─ 壊れ記事を Desktop キューから削除
├─ published/ の全記事を hb.afl.rakuten.co.jp 含有チェック
├─ 公開数 < TARGET → ⚠ 未達警告
└─ 問題あり → osascript macOS通知 + logs/audit-YYYY-MM-DD.log
daily.sh 的核心是一个不到 10 行的 NEED 计算。以下是实际代码(第 16–23 行)。
# 今日すでに公開できた本数(published/ の本日プレフィックス)
PUB_TODAY=$(find "$ARCHIVE" -maxdepth 1 -name "${TODAY}_*.md" 2>/dev/null | wc -l | tr -d ' ')
# Desktop直下に残っている未公開ドラフト(持ち越し+前段で作ったが未投稿の分)
DRAFTS=$(find "$OUT" -maxdepth 1 -name '*.md' 2>/dev/null | wc -l | tr -d ' ')
# 目標到達に必要な新規生成本数 = 目標 − 本日公開済 − 手元ドラフト
NEED=$((TARGET - PUB_TODAY - DRAFTS))
[ "$NEED" -lt 0 ] && NEED=0
echo "[daily] $TODAY $(date '+%H:%M') 本日公開済: ${PUB_TODAY}本 / ドラフト: ${DRAFTS}本 / 目標: ${TARGET}本 → 生成: ${NEED}本"
TARGET 在脚本顶部硬编码为 TARGET=5(在 audit-heal.sh 中是 ${AFFILIATE_FACTORY_TARGET:-3}——一种默认值为 3、可通过环境变量外部修改的设计)。
重要的是,"剩余草稿"被纳入了 NEED 计算。如果一篇文章是在前一次运行中生成的,但发布失败、仍停留在 Desktop 上,下一次运行不会生成更多——只会重试发布。这将"生成成本(Claude API 消耗)"与"发布重试"分离了。
generate.sh 中的生成循环(第 269–276 行)允许每篇文章最多重试三次。
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
resp_is_valid() 中的判断逻辑(第 251–257 行)同样具体。
resp_is_valid() {
local r="$1"
[ -z "$r" ] && return 1
# PRODUCT行が無い/タイムアウト等のエラー文/極端に短い応答は失敗扱い
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
}
如果三次尝试全部失败,generate.sh 会以 exit 1 退出,不写入损坏的文章(第 280–282 行)。以损坏的状态写入文件会污染队列,为下游的 audit-heal.sh 带来清理工作。这个检查堵住了这个成本。
提示词严格强制第一行输出产品名称格式:PRODUCT: <正式商品名>。正是这一行使得产品名提取(第 285 行)和正文提取(第 287–289 行)变得可靠。如果让模型产生模糊的输出,下游的每个解析都会崩溃,所以强制输出格式是必须的。
Rakuten API 最大的坑是 400 Bad Request:"keyword is not valid"错误。产品名称中的独立 token——比如"Narwal Freo Z Ultra"中的"Z"或"Ultra"——会被 Rakuten 的搜索引擎拒绝。
修复方案是 resolve() 函数(第 152–179 行)中的"每次修剪一个末尾词并重试"策略。
def resolve():
words = product.split()
brand = words[0].lower() if words else ""
tried = set()
for n in range(len(words), 0, -1):
keyword = " ".join(words[:n]).strip()
if not keyword or keyword in tried:
continue
tried.add(keyword)
try:
result = fetch(keyword)
except Exception as exc:
print(f"[generate] 楽天API検索に失敗({keyword}): {exc}", file=sys.stderr)
return None
if result:
if not brand or brand in (result["name"] + " " + result["url"]).lower():
return result["url"]
# ブランド不一致=別商品に化けた。
print(f"[generate] 候補がブランド不一致({keyword}→{result['name'][:30]})。検索リンクへ。", file=sys.stderr)
return None
time.sleep(1.0)
return None
流程是"Narwal Freo Z Ultra"→"Narwal Freo Z"(400)→"Narwal Freo"(命中)。但如果裁剪过度,"Narwal"单独可能匹配到某个完全不同的、评价很高的商品。所以它通过 brand in (result["name"] + " " + result["url"]).lower() 验证品牌名(第一个词)是否出现在返回的商品名称或 URL 中,如果不匹配,就判定"这个变成了别的商品"并停止继续裁剪。
429(速率限制)会在 fetch() 内部(130–148行)以 time.sleep(1.5) 间隔重试最多两次。
当某个商品的独立链接实在无法获取时,resolve() 返回 None。接下来(182–190行)就是关键的兜底方案。
affiliate_url = resolve()
if not affiliate_url:
# 個別商品が取れない時は、アフィリ計測付き検索リンク(hgc)にフォールバック=必ず報酬が乗る。
search_url_enc = quote(search_url, safe="")
affiliate_url = (
f"https://hb.afl.rakuten.co.jp/hgc/{affiliate_id}/?pc={search_url_enc}&m={search_url_enc}"
)
print(f"[generate] 商品個別リンクを取得できず検索リンクにフォールバック: {product}", file=sys.stderr)
print(affiliate_url)
hb.afl.rakuten.co.jp/hgc/ 是乐天联盟营销的搜索链接,带有联盟追踪。它不指向单个商品,而是指向"在乐天市场上搜索该商品名的结果页面"——但佣金仍然有效。
如果任一环境变量 RAKUTEN_APPLICATION_ID / RAKUTEN_ACCESS_KEY / RAKUTEN_AFFILIATE_ID 为空,就会完全跳过 API 调用,回退到裸搜索 URL(https://search.rakuten.co.jp/search/mall/…)(83–87行)。那种状态下佣金为零。在生产环境运行而没注意到 .env 配置错误,是零联盟佣金的典型原因。
Claude Code 生成的文章正文中有时包含裸的乐天搜索 URL。即使 prompt 指示插入联盟链接,模型有时也会写非联盟的 URL。如果原样发布,佣金就是零。
postprocess_body()(201–246行)在后处理中解决这个问题。
# 2) claudeが本文に書いた実リンク [楽天で「…」を探す](任意URL) を、正しいアフィリリンクに丸ごと差し替える。
# (これをやらないと claude が書いた非アフィリの検索URLがそのまま残る=報酬ゼロになる)
rakuten_md_link_re = re.compile(r"\[楽天で「[^」]*」を探す\]\([^)]*\)")
body = rakuten_md_link_re.sub(lambda m: link, body)
# 3) 念のため、楽天ドメインを指す素のmarkdownリンクも差し替える
rakuten_any_re = re.compile(r"\[[^\]]+\]\((?:https?:)?//[^)]*rakuten\.co\.jp[^)]*\)")
body = rakuten_any_re.sub(lambda m: link, body)
还有占位符处理(219–224行)。Claude 有时会写占位符如(▼楽天で「〇〇」を検索してリンクを貼る),这些也会被正则表达式检测并替换。
通过这三层替换逻辑,无论链接以何种方式写出,最终都会收敛到正确的联盟链接。这就是"可靠地嵌入变现链接"的实际含义。
post-to-hatena.sh 的 --all 模式使用 posted-hatena.log 进行跳过检测(40–61行)。它将每个已发布文件的完整路径记录到日志中,因此即使下次运行时同一个文件仍在队列中,也不会被重复发布。
for f in "$OUT"/*.md; do
[ -e "$f" ] || continue
if /usr/bin/grep -qxF "$f" "$POSTED_LOG"; then continue; fi
# 生成失敗の残骸は投稿しない
if /usr/bin/grep -qE '不明な商品|Request timed out' "$f"; then
echo "[hatena] スキップ(生成失敗の残骸): $f" >&2; continue
fi
if post_one "$f"; then
echo "$f" >> "$POSTED_LOG"; found=$((found+1))
[ -z "$DRAFT_FLAG" ] && mv "$f" "$ARCHIVE/" && echo "[hatena] アーカイブへ移動: $(basename "$f")"
fi
done
只有使用 --publish 标志运行时,才会将已发布的文件移入 published/ 目录。从桌面队列中移除它们会增加"今日已发布数量",因此下次 daily.sh 运行时 PUB_TODAY 会正确计数。这个归档移动是幂等设计中的一个齿轮。
因为 daily.sh 调用它时是 post-to-hatena.sh --publish --all(36行),发布和归档自动一起发生。
最后一步是 audit-heal.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
设计本身已通过 resp_is_valid() 在写入时拒绝损坏的文章,但这是针对过去运行或手动干预中漏进队列的案例的安全网。包含"不明な商品"或"Request timed out"的文件会被直接删除。
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。如果 postprocess_body() 正常工作,它们都会返回 ✓アフィリ;如果替换因某种原因失败,在这里会被捕获。
if [ "$published" -lt "$TARGET" ]; then
log " ⚠ 公開が目標未達(生成 or 公開が失敗した可能性)"
problems=$((problems+1))
fi
if [ "$problems" -gt 0 ]; then
log " ❌ 監査NG: 要確認 (${problems}件)"
notify "監査NG: 公開${published}/${TARGET}本・非アフィリ${bad_link}本。logs/audit-${TODAY}.log を確認"
exit 1
else
log " ✅ 監査OK: ${published}本すべてアフィリリンク付きで公開"
exit 0
fi
notify() 通过 osascript -e "display notification..." 推送到 macOS 通知中心。日志文件保留在 logs/audit-YYYY-MM-DD.log,因此事后可以追溯任何问题的时间和内容。
当审计以 exit 1 结束时,daily.sh 也会在控制台打印"⚠ 監査NG"(40行)。查看 launchd 的执行日志可以了解哪次运行发生了什么问题。
Next time I'll write about the failures I ran into while actually standing this thing up (a recurrence of zero commissions caused by a vanished .env, a corrupted launchd plist, and the self-healing watchdog destroying files on its own), plus design guidelines for not repeating them.
文章工厂遇到的第一个问题是重复内容。launchd 每天运行意味着每天告诉 Claude"为你选择的类别写一篇关于新商品的文章"。不做任何处理就会陷入地狱——30 篇机器人吸尘器文章全写"Roborock S8 Pro Ultra"。
解决方案是 posted-products.log(实际路径是 $AFFILIATE_FACTORY_LOG)加上将其嵌入 prompt 的机制。generate.sh 的 8–19行就是该实现。
LOG="${AFFILIATE_FACTORY_LOG:-$DIR/posted-products.log}"
# ...
touch "$LOG"
# 既出商品(重複回避用)
EXCL=$(paste -sd '、' "$LOG" 2>/dev/null)
[ -z "$EXCL" ] && EXCL="(まだ無し)"
这个 EXCL 变量作为 $EXCL 注入到 prompt 的 # 除外(これらの製品は今回選ばない) 部分。每次生成文章时,一个商品名称会被追加到文件末尾(298行),所以一旦累积了 100 篇文章,排除列表中就排列了 100 个商品。
[ "$PRODUCT" != "不明な商品" ] && echo "$PRODUCT" >> "$LOG"
关键是这一点:通过对模型的约束是通过 prompt 传达的。试图在代码中做重复检查需要字符串匹配来吸收标题变体和品牌差异,异常处理会膨胀。只要"把之前选过的商品名称列表交给它,并告诉它不要选这些",Claude 就会合理地避开它们。这里展示的哲学:积极地把模型能处理的工作交给模型。
generate.sh 的第 12行是这样写的,一行搞定。
[ -f "$DIR/.env" ] && set -a && . "$DIR/.env" && set +a
set -a 是一种模式,会自动导出此后定义的所有变量;set +a 则关闭该模式。仅靠 source .env,在某些 bash 行为下可能无法将变量传递到子进程。由于 Rakuten API 凭据(RAKUTEN_APPLICATION_ID 等)需要到达 Python3 子 shell,因此通过 set -a 启用 export 来加载它们。
如果 .env 不存在,什么都不会发生。这是标准做法——不将 .env 提交到 git,只提交 .env.example——但"当 .env 缺失时脚本不会退出"这一点出人意料地重要。launchd 也会在系统启动时触发,因此如果 .env 不存在,它会在空环境变量下运行。此时 RAKUTEN_APPLICATION_ID 未定义,第 83–86 行的条件会回退到 rakuten_search_url()——也就是说它会继续写出"带裸链接、无收益的文章"。脚本不会崩溃,但收益为零:这是最糟糕的静默失败。我实际上就犯过这个错误,下文"我卡住的地方"会详细描述。
"不要抠门"的 GEN_TIMEOUT 哲学
generate.sh 第 260–263 行的注释保留了我反复试错的痕迹。
# フル記事生成の所要時間。従来(2500字・検索数回)で約260sだったが、本文を8000〜10000字+
# 競合3製品の追加WebSearchに増やしたため生成が伸びる。余裕を持って実測の数倍を確保する。
# 短いと長文の途中でkillされ空応答→0本公開になるため、ここはケチらない。
GEN_TIMEOUT="${AFFILIATE_FACTORY_GEN_TIMEOUT:-1200}"
我最初设置的是 GEN_TIMEOUT=300。简单生成包含几次 WebSearch 在内需要 4–5 分钟。但当我加入指令"同时 WebSearch 三款竞品并在写作前确认真实规格"后,平均耗时延长到了 12–15 分钟。timeout 300 会在 300 秒时 kill 掉进程,所以 Claude 在文章写到一半时被强制终止,RESP 返回空响应。resp_is_valid() 拒绝了空响应,三次重试全部失败,exit 1——这就是"当天一篇文章都没生成"的真正原因。
1200 秒(约 20 分钟)大约是实测时间的 1.5 倍。它通过 AFFILIATE_FACTORY_GEN_TIMEOUT 可覆盖,因此如果将来缩短 prompt,可以调整这个值而无需改动代码。"默认值放在环境变量中,必要时可从外部覆盖"这一模式在整个脚本中一致应用(AFFILIATE_FACTORY_OUT、AFFILIATE_FACTORY_LOG 和 AFFILIATE_FACTORY_TARGET 都采用相同的结构)。
使用 AFFILIATE_FACTORY_TEST_RESPONSE 进行 mock 测试
Claude API 每次调用都会计费。每次测试逻辑变更都要调用真实 API,既费钱又费时间。这就是为什么 AFFILIATE_FACTORY_TEST_RESPONSE 在 generate.sh 第 265–266 行被接入。
if [ -n "${AFFILIATE_FACTORY_TEST_RESPONSE:-}" ]; then
RESP="$AFFILIATE_FACTORY_TEST_RESPONSE"
else
for attempt in 1 2 3; do
RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" ...)
在这个环境变量中传入一个 dummy 响应,然后运行脚本,就可以在不调用 API 的情况下遍历从 resp_is_valid() → 产品名提取 → postprocess_body() → 文件写入的整个流程。例如:
export AFFILIATE_FACTORY_TEST_RESPONSE='PRODUCT: テスト掃除機 X100
# 【2025年】テスト掃除機 X100 全スペック解説
> ※本記事はコラボレーションプログラムを利用しています。
[:contents]
## この記事でわかること
テストすることです。'
bash generate.sh 1
这样可以一次性验证乐天链接是否被正确替换、文件名是否带时间戳创建、以及产品名是否被追加到 posted-products.log。它走的是真实的生产代码路径,所以与单元测试不同,这是对实际行为的检验。
Hatena Markdown 的一个特定陷阱:[:contents] 与声明块引用后的空行
在 postprocess_body() 末尾,最终输出组装顺序是硬编码的(generate.sh 第 240 行)。
out = [title, "", disclaimer, "", contents]
注意两个 "" 条目。标题后有一个空行,声明块引用(disclaimer blockquote)后也有一个空行,然后才是 [:contents](目录)。
最初我写成 [title, disclaimer, contents] 紧凑排列。结果目录在 Hatena Blog 上神秘地不显示,或者被吸入声明块引用中破坏了布局。经调查,发现 Hatena 的 Markdown 处理器有时会将紧跟在以 > 开头的块引用行之后的 [:contents] 视为块引用的延续——这是其规范的一个 quirks。
插入一个空行后,解析器就能判断"块引用在这里结束",[:contents] 就会被解析为独立的目录指令。这是 Hatena Markdown 特有的问题。普通 Markdown 渲染器、note 或 Zenn 都不会出现这种情况。
post-to-hatena.sh 的配置守卫与 H1 分离
post-to-hatena.sh 在脚本顶部附近有一个守卫(第 19–24 行)。
CFG="$HOME/.config/blogsync/config.yaml"
if [ ! -f "$CFG" ] || /usr/bin/grep -q 'REPLACE_' "$CFG"; then
echo "[hatena] スキップ: $CFG が未設定です(はてなID/APIキー未入力)。" >&2
exit 0
fi
如果 blogsync 配置文件中仍然存在字符串 REPLACE_(即模板从未被填写),它会静默地 exit 0 什么都不做。从 daily.sh 调用时,这被视为"0 篇文章"。没有这个守卫,脚本会在配置被遗忘的情况下运行,blogsync 会抛出错误,整个 daily.sh 可能中止。
另一个重要部分是 post_one() 中的标题分离(第 26–38 行)。
title="$(sed -n 's/^# //p' "$file" | head -1)"
[ -z "$title" ] && title="$(basename "$file" .md)"
body="$(awk 'NR==1 && /^# /{next} {print}' "$file")"
它从 Markdown 文件第一行提取 # タイトル,传递给 blogsync 的 --title 参数,然后将该行移除后的正文发布。没有这个处理,标题会作为"H1 标题"进入文章正文,导致 Hatena Blog 文章标题和文内 H1 重复。这对 SEO 和视觉效果都很糟糕,因此将正文中的 H1 剥离并传递给 --title 是正确的设计。
.env 消失导致零佣金——我的第二次我第一次注意到这个问题是一周后打开 Rakuten Affiliate 面板时。记录显示每天发布了 5 篇文章,但佣金图表丝毫没有变动。
回溯 audit-heal.sh 的日志,它们显示的发布时间正常,没有报错,没有任何异常迹象。Claude Code 运行了,文章生成了,发布也成功了。但 Rakuten API 凭据从未加载——因为 .env 缺失——所以所有文章中的链接都是"裸链接,无法追踪"。没有佣金,没有告警,没有任何东西表明出了问题。
我重新创建了 .env,运行了 launchctl kickstart,第二天收益图表恢复了。这次我学到了:.env 文件不存在时,generate.sh 应该输出一个明确的错误而不是静默继续。在下一版本中我加入了 set -a 前后的守卫检查,使缺失的环境变量变成可见的致命错误而非静默的收益为零。
审计日志显示文章发布时间戳正常、Claude Code 执行正常、文章发布正常。但 Rakuten API 凭据从未加载——因为 .env 缺失——所以所有链接都是无法追踪的裸链接。