X長文記事を「人間5%チェック」で量産する ― 自己採点で9点だけ通す
Claude Codeの自動化基盤を積み上げてきた「個人開発を量産するための自動化基盤」シリーズです。今回は、X(旧Twitter)の長文記事(Article)を Claude Code で量産しながら、品質を落とさないために設計した「人間5%チェック」ゲートの話をします。
なぜ「完全自動」にしなかったのか
AI で文章を書かせると、すぐ「良さそうだけど読み終えたあとに何も残らない」記事が量産されます。情報の正確さよりも、一次情報の有無が読後感を決める、というのが私の実感です。
最初はシンプルに「生成→品質チェック→Xに自動投稿」という三段構成を目指しました。シェルスクリプトで全部繋いで、cron で回す。技術的には難しくありません。実際に動かしてみると、7点台の均質な記事が淡々と投稿されるだけでした。読んで学べるけれど「これ誰が書いたの?」という没個性の文章。Lilyの発信として成立しない。
原因は明快でした。Claude は「平均点を狙って書く」のが得意です。誰も傷つけない、正確だけど刺激もない記事を生成する傾向があります。9点の記事を出すには、最後のゲートを人間が持つ必要がある、というのが出した結論です。
「Claude が量産し、人間が5%だけ触って9点を選ぶ」という設計にしました。完全自動は諦め、人間の判断を最小限の工数に圧縮することに集中しました。
ファクトリーの全体像
~/dev/x-article-factory/ のディレクトリ構成は次のとおりです。
x-article-factory/
├── generate.sh # Claude に起草させ、自己採点する
├── review-gate.sh # 人間がTUIで承認/却下する
├── daily.sh # launchd ラッパー(毎日2本起草)
├── knowledge/
│ ├── persona.md # Lily のペルソナ・CTA
│ ├── style.md # 文体・テンポルール
│ ├── post-types.md # 使える型(How-to / 失敗談 / ビフォーアフター等)
│ ├── quality-rubric.md # 自己採点10項目の基準
│ └── hooks.md # 冒頭フックのパターン集
├── drafts/ # 品質ゲートを通過した下書き
├── approved/ # 人間が承認した記事(投稿待ち)
├── rejected/ # 人間が却下した記事
└── logs/
├── gen.log # 生成ログ(OK / REJECTED_LOWSCORE)
├── posted-types.log # 使った型の履歴(直近3件を重複回避に使う)
└── posted-topics.log # 使ったテーマの履歴(直近8件を重複回避に使う)
処理フローは3段階です。
[launchd] daily.sh
↓ N本ループ(デフォルト2本)
generate.sh
↓ Claude が起草 → 自己採点(avg ≥ 7.0 のみ通過)
drafts/YYYY-MM-DD_HHMMSS.md
↓ bash review-gate.sh(人間がTUIで判断)
approved/ ← 9点だけ通す
rejected/ ← 7〜8点台でも刺さらなければ落とす
↓ Xの予約投稿に手動貼り付け
「AIが生成・自己採点し、人間が最終判断するだけ」という役割分担です。
generate.sh:Claude が書いて自分で採点する
生成の核心は generate.sh です。設計を順に追います。
環境変数でチューニングできる設定値
MODEL="${X_ARTICLE_MODEL:-sonnet}"
GEN_TIMEOUT="${X_ARTICLE_GEN_TIMEOUT:-420}"
MIN_AVG="${X_ARTICLE_MIN_AVG:-7.0}"
モデルは sonnet、タイムアウトは420秒(7分)、品質閾値は7.0がデフォルトです。長文記事の起草は生成に時間がかかるため、タイムアウトを余裕を持って設定しています。すべて環境変数で上書きできるようにしており、「閾値を上げて質を絞る」「別モデルで試す」といった実験を .env 一行で切り替えられます。
また、PAUSED ファイルが存在するとスクリプトが即座に終了する仕掛けがあります。
[ -f "$DIR/PAUSED" ] && { echo "[gen] PAUSED。停止中。"; exit 0; }
暴走したり想定外の記事が出始めたとき、touch ~/dev/x-article-factory/PAUSED だけで全停止できます。
ナレッジを5ファイルに分けてプロンプトに積む
KNOW=""
for f in persona hooks style post-types quality-rubric; do
KNOW+=$'\n\n===== '"$f"$' =====\n'"$(cat "$DIR/knowledge/$f.md")"
done
Claude への指示書を5つのファイルに分けて管理しています。それぞれの役割は次のとおりです。
| ファイル | 内容 |
|---|---|
| persona.md | Lily のペルソナ設定、出口CTAのパターン、発信上の禁止事項 |
| style.md | 文体・テンポ・説得力のルール(具体的な言い回し含む) |
| post-types.md | 使える型の一覧(番号付き。型名・構成パターン・適したテーマ例) |
| quality-rubric.md | 自己採点10項目の採点基準(各項目の1〜10点の定義) |
| hooks.md | 冒頭フックのパターン集(問いかけ型・衝撃事実型・ビフォーアフター型等) |
一つの巨大なプロンプトにするよりも、ファイルを分けて管理することで「style.md だけ更新する」「CTA文言だけ変える」という細かいチューニングがしやすくなりました。
grounding:一次情報を食わせてAI量産品との差を作る
これがファクトリー設計の一番のポイントです。
GROUND=""
for src in "$HOME/.remember/recent.md" "$HOME/.remember/now.md" \
"$HOME/Documents/claude-obsidian/wiki/hot.md"; do
[ -f "$src" ] && GROUND+=$'\n\n--- '"$(basename "$src")"$' ---\n'"$(head -c 4000 "$src")"
done
[ -z "$GROUND" ] && GROUND="(作業ログ無し。一般的なClaude Code/個人開発の知見で書く)"
Claude に渡す「一次情報」として3つのソースを読み込みます。
~/.remember/recent.md— 直近の作業メモ~/.remember/now.md— 今やっていること~/Documents/claude-obsidian/wiki/hot.md— Obsidian のホットサマリー(約500語)
それぞれ head -c 4000 で4,000バイトまで読み込み、プロンプトに渡します。プロンプト中の指示は「固有名・数字・失敗を必ず拾って本文に入れる。借り物の一般論にしない」というものです。この2点セットが機能します。
たとえば「iOSアプリの審査が通った」「スクリプトのある処理でエラーが出た」「今週リリースした機能のインプレッション数」といった情報がgroundingに入っていると、Claudeはそれを主役にして書こうとします。AI量産品との差はここで生まれます。
全ファイルが空のときは「作業ログ無し」にフォールバックして、一般的な知見ベースで書きます。品質は落ちるので、記事生成の前に作業ログを更新しておく習慣が重要です。
直近の型・テーマを履歴から引いて重複を避ける
LAST_TYPES=$(tail -3 "$TYPES_LOG" | paste -sd '、' -); [ -z "$LAST_TYPES" ] && LAST_TYPES="(まだ無し)"
LAST_TOPICS=$(tail -8 "$TOPICS_LOG" | paste -sd '、' -); [ -z "$LAST_TOPICS" ] && LAST_TOPICS="(まだ無し)"
型の履歴は直近3件、テーマの履歴は直近8件をClaudeに渡します。「これらは避けて別の型を選ぶ」「焼き直しを避ける。違う切り口にする」という指示をセットで与えることで、同じパターンの記事が連続するのを防ぎます。
テーマを8件と型より多く見るのは、テーマの枯渇が先に来るからです。型は10種類以上あっても、テーマの「Lilyレーン(Claude Code / 個人開発 / AI自動化)」は絞られています。
出力フォーマット:メタ情報+本文の厳密な構造
Claudeへの出力フォーマット指定は厳密にしています。
1行目: TYPE: <使った型の名前(post-types.mdの番号と名前)>
2行目: TOPIC: <この記事のテーマを15字以内で>
3行目: SCORE: {"hook":N,"value":N,"specificity":N,"firsthand":N,"tempo":N,"reproducibility":N,"structure":N,"surprise":N,"style":N,"cta":N,"avg":N.N}
4行目以降: 記事本文のMarkdown(1行目は「# タイトル」)
SCOREのJSONには10項目が入ります。Claude が自分で書いた記事を10軸で採点します。
| 項目 | 採点の観点 |
|---|---|
| hook | 冒頭フックの引力。読者が続きを読む気になるか |
| value | 読者への実質的な価値提供。読んで何かが変わるか |
| specificity | 具体性・固有名の多さ。抽象論になっていないか |
| firsthand | 一次情報の有無。実作業ベースか借り物の知識か |
| tempo | 文章のテンポ・読みやすさ。スクロールが止まらないか |
| reproducibility | 再現性。読者が自分でも試せるか |
| structure | 構成の論理性。流れに無理がないか |
| surprise | 意外性・発見。読者が「知らなかった」と感じるか |
| style | Lily の文体との一致。ペルソナとずれていないか |
| cta | CTAの自然さ。押しつけがましくないか |
品質ゲート:avg < 7.0 は drafts に出さない
AVG=$(printf '%s' "$SCORE" | python3 "$DIR/lib/parse_avg.py" 2>/dev/null)
[ -z "$AVG" ] && AVG=0
if python3 -c "import sys; sys.exit(0 if float('$AVG') >= float('$MIN_AVG') else 1)" 2>/dev/null; then
:
else
echo "[gen] 品質スコア avg=$AVG < $MIN_AVG。棄却(drafts に出さない)。topic=$TOPIC" >&2
echo "$(date '+%F %T') REJECTED_LOWSCORE avg=$AVG $TOPIC" >> "$DIR/logs/gen.log"
exit 2
fi
SCOREのJSONから avg を取り出し、7.0未満なら棄却します。棄却した場合は drafts/ に出力せず、ログにも REJECTED_LOWSCORE として記録します。
「Claudeが自己採点して avg 7.0未満なら、そもそも人間の目に入れない」というのが設計の意図です。7点台の記事を人間がレビューする工数をゼロにします。ログを確認すると、起草した記事の2〜3割がこのゲートで棄却されています。
「7.0通過 = 良い記事」ではありません。Claude の自己採点は甘い傾向があり、実際に読むと「7.2点だけど刺さらない」記事は多くあります。7.0のゲートは「明らかな失敗作を弾く」ためのフィルターです。9点の記事を選ぶのは人間の仕事です。
品質ゲートを通過したものは drafts/ に出力します。
TS=$(date +%Y-%m-%d_%H%M%S)
FNAME="$DIR/drafts/${TS}.md"
{
printf -- '\n\n' "$TYPE" "$TOPIC" "$AVG" "$TS"
printf '%s\n' "$BODY"
} > "$FNAME"
ファイル名はタイムスタンプ(2026-06-26_142030.md 形式)。先頭行にHTMLコメントでメタ情報を埋め込みます。review-gate.sh でこのメタ行を表示することで、レビュー時に「型・テーマ・スコア」が一目でわかります。
バリデーションと3回リトライ
resp_is_valid() {
local r="$1"
[ -z "$r" ] && return 1
printf '%s' "$r" | grep -q '^TYPE:' || return 1
printf '%s' "$r" | grep -q '^SCORE:' || return 1
printf '%s' "$r" | grep -qiE 'request timed out|usage limit|rate limit' && return 1
[ "$(printf '%s' "$r" | wc -c | tr -d ' ')" -lt 600 ] && return 1
return 0
}
生成結果のバリデーション関数です。TYPE行の存在、SCORE行の存在、タイムアウト/レートリミットエラーの不在、最低600バイトの本文量を確認します。いずれかが欠けていれば無効とみなし、最大3回リトライします。
for attempt in 1 2 3; do
RESP=$(timeout "$GEN_TIMEOUT" "$CLAUDE" -p "$PROMPT" --allowedTools WebSearch --model "$MODEL" </dev/null 2>/dev/null)
resp_is_valid "$RESP" && break
echo "[gen] 生成失敗(試行${attempt}/3)。再試行…" >&2
RESP=""
done
--allowedTools WebSearch を付けているのは、Claudeが「今の旬を確認する」のにWebSearchを使えるようにするためです。プロンプトに「必要なら WebSearch で今の旬を確認」と指示しており、タイムリーなネタを拾う際に機能します。
review-gate.sh:人間が5%だけ触るTUI
品質ゲートを通過した drafts/ の記事を、人間がTUIで1本ずつレビューします。
echo "未チェックの下書き: ${#drafts[@]} 本"
echo "操作: [a]承認 [s]保留 [d]却下 [e]編集 [q]終了"
操作は5択です。
| キー | 動作 |
|---|---|
| a | approved/ へ移動(投稿待ちキューに入る) |
| s | drafts に残す(後で再判断) |
| d | rejected/ へ移動 |
| e | $EDITOR で開いて編集してから再判断 |
| q | 終了 |
プレビューはメタ行(型/テーマ/スコア)+本文先頭60行を表示します。
head -1 "$f" | sed 's///' # メタ行(型/テーマ/スコア)
tail -n +2 "$f" | sed '/./,$!d' | head -60
TUIのポイントは「全文を読まなくてもよい設計」です。先頭60行のプレビューで「これは承認か否か」を直感的に判断できるように意図しています。スコアが7.5以上でも、読んで「Lilyらしくない」「一次情報がほぼ一般論」と感じたら d で即却下します。
反対に、スコアが7.2でも「このエピソードは刺さる」と感じたら e でエディタを開いて微修正して承認することもあります。
レビュー終了後の出力はシンプルです。
echo "承認済み: $(ls "$DIR"/approved/*.md 2>/dev/null | wc -l | tr -d ' ') 本(approved/)"
echo "次: 承認分をXの予約投稿に貼る → 投稿したら approved/ から消す"
承認した記事をXの予約投稿に手動で貼り付け、投稿後は approved/ から削除します。承認→投稿→削除のサイクルを回すことで、キュー管理が ls approved/ だけで完結します。
実際の通過率感覚として、生成10本に対して承認1〜2本(10〜20%)が現実的な数字です。「7点を10本投稿するより、9点を1〜2本投稿する方が総合的なエンゲージメントが高い」という仮説で運用しています。
daily.sh:launchd で毎日2本起草する
N="${X_ARTICLE_DAILY_N:-2}"
# nvm default の PATH を通す(launchd最小環境対策)
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1
[ -f "$DIR/PAUSED" ] && { echo "[daily] PAUSED"; exit 0; }
ok=0
for i in $(seq 1 "$N"); do
if bash "$DIR/generate.sh"; then ok=$((ok+1)); fi
sleep 2
done
echo "[daily] $(date '+%F %T') 起草 $ok/$N 本 → 人間チェック: bash review-gate.sh"
launchd のラッパースクリプトです。デフォルトは毎日2本起草します(X_ARTICLE_DAILY_N で変更可能)。
重要なのは nvm の PATH 解決です。launchd の最小環境には nvm が入っておらず、素の状態では claude: command not found になります。nvm.sh を明示的に source することで解決しています。これを忘れると、シェルの対話環境では動くのにlaunchd経由でだけ落ちる、という混乱した状況になります。
sleep 2 を挟んでいるのは安定性対策です。N本を並列で投げるより、2秒待ってから次の1本を投げる方が API 呼び出しが安定しています。
ループ内では generate.sh の終了コードを見て成功カウントを取ります(品質ゲートで棄却された場合は exit 2 が返るため、ok に加算されません)。最終的に「起草 1/2 本」のような形で実績が出力されます。
運用してわかった設計のポイント
しばらく実際に動かしてみて、いくつかのことが見えてきました。
品質ゲートの閾値7.0は現実的な下限
最初は6.5で試しましたが、6点台後半の記事は「読めるが薄い」ものが多く、review-gate.sh で全部 d になっていました。7.0にしてからは、通過したものの多くが「読んで面白い」に近くなりました。一方で8.0に上げると通過率が下がりすぎて、drafts がなかなか溜まらなくなります。7.0〜7.5が現実的な運用範囲です。
groundingのファイルが充実しているほど記事の密度が上がる
Obsidianのhot.mdが500語以上書かれている日と、ほぼ空の日で、明らかに記事の具体性が違います。「今日何をやったか」「今週でた数字」を小まめにメモしておく習慣が、記事品質に直結します。作業ログを書くことは記事のための仕込みでもあります。
型の履歴管理(直近3件)は重複抑制に効く
同じ型(たとえば「失敗談型」)を連続して出す傾向があったため、直近3件を避けるよう指示しました。これだけで型の多様性が増しました。テーマは直近8件を見るのは、同じレーン内でのネタ枯渇が早いからです。型よりテーマの方が枯渇しやすい。
e(編集)オプションは意外と使う
review-gate.sh の e キーは「$EDITORで開いて編集してから再判断」です。最初は「編集は手間だからほぼ使わない」と思っていましたが、実際には「全体は良いが最後のCTAだけ修正したい」「1文だけ一次情報として自分の数字を追記したい」という局面で重宝します。AIが書いた8割の土台に人間が2割を足す、という形が一番エンゲージメントが高い記事になりやすいです。
踏んだ落とし穴
-
launchd から
claude: command not found→ nvm の PATH が通っていない。daily.shで[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"を source しないと、対話シェルでは動くのに launchd からだけ失敗する。デバッグに時間を取られた -
</dev/nullを付け忘れて対話待ちフリーズ →claude -pはstdinを閉じないと対話モードになる場合がある。タイムアウト420秒間ずっと張り付いてから失敗する。</dev/nullで必ずstdinを閉じること -
SCORE行のJSONが壊れて
AVG=0で全棄却の連続 → Claudeが改行やコードフェンスをSCORE行に混入させることがある。parse_avg.pyがJSONパースに失敗してAVG=0になり、全記事が棄却される。バリデーションのresp_is_valid()を厳密に書いておく必要があった -
**本文に `
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 作ったアプリは ポートフォリオ にまとめています📱
- 新着・開発の裏側は X @bokuwalily で発信しています🌍
- OSS: github.com/bokuwalily 🐙
皆さんの ❤️ やシェアが励みになります!