# Threads 自動投稿システム 構築指示書（Claude Code 用）

このファイルは、**Claude Code にそのまま渡すための構築指示書**です。
受け取った人は、このファイルを自分の Claude Code の作業フォルダに置いて、

> 「この指示書の通りに Threads 自動投稿システムを作って」

と言うだけでOKです。実際に稼働している実装（後藤あい / Lavique の Threads 運用システム）をもとにしています。

---

## 0. これは何？（完成すると出来ること）

- 毎日決まった時間（例: 朝7:30・昼12:30・夜21:00）に、Threads へ自動投稿される
- 投稿は「フック（1投稿目）＋続き（2投稿目・自分への返信）」の2連投稿
- 投稿の元ネタは「ストック棚」（スマホで開ける承認制のWebページ）に貯めておく
  - AI（Claude Code）が下書きを量産 → 本人がスマホで読んで、編集して、承認したものだけが投稿される
  - 勝手に変なものが投稿されることは絶対にない
- ストック棚の機能: 編集 / 承認・承認解除 / 削除 / まとめて承認 / 新規追加 / 予約日指定（📅この日に投稿）/ 🎤音声で直す（想いを話すとAIがトーンを保って書き直す）/ 📷写真を付ける（任意。付けた投稿だけ1投稿目が写真付きになる。スマホから選ぶだけ・自動圧縮）
- トークンは自動リフレッシュ（60日期限を毎日延命）するので、一度設定したら放置でOK
- 承認済みストックが減るとLINEでお知らせ（任意設定）
- 費用: Cloudflare 無料枠内で動く。Threads API も無料

---

## 1. 前提（必要なもの）

| もの | 備考 |
|---|---|
| Claude Code | この指示書を実行する本体 |
| Cloudflare アカウント | 無料でOK。`npx wrangler login` 済みにしておく |
| Threads アカウント | **公開アカウントであること**（非公開だとトークン生成ツールが使えない） |
| Meta（Facebook/Instagram）アカウント | Meta for Developers への開発者登録に使う |

---

## 2. 作業の分担

| 誰が | 何を |
|---|---|
| 人間（本人） | Meta開発者登録・アプリ作成・Threadsテスター承認・トークン発行（Part A、初回だけ・15〜30分） |
| Claude Code | それ以外ぜんぶ（DB作成・Worker実装・デプロイ・テスト投稿・投稿文の量産） |

---

## Part A. Meta アプリの準備（人間の作業）

⚠ ここはアカウントのログインが必要なので、**本人が普段使っているブラウザ（Chromeなど）** でやること。
Claude Code の埋め込みブラウザや別環境からの Threads ログインは、Meta にブロックされて「エラーが発生しました」になりやすい（実体験）。

1. https://developers.facebook.com にアクセスし、Meta アカウントでログイン
   → 初めての場合は「開始する」から開発者登録（規約同意・SMS認証・メール認証・役割は「経営者/創設者」でOK）
2. 「マイアプリ」→「アプリを作成」
   - アプリ名: 好きな名前（例: MyThreadsAuto）
   - ユースケース: **「Threads API にアクセス」を選ぶ**（ここ大事）
   - ビジネスポートフォリオ: 「現時点ではリンクしない」でOK
3. 作成後、左メニュー「ユースケース」→ Threads API の「カスタマイズ」→「アクセス許可と機能」で以下を **追加**:
   - `threads_content_publish`（投稿に必須）
   - `threads_manage_replies`（2投稿目=返信に必須）
   - `threads_manage_insights`（反応レポートを出したい場合）
   - ※ `threads_basic` は最初から入っている
4. 左メニュー「アプリの役割」→「役割」→「メンバーを追加」→ 役割は **「Threadsテスター」** → 自分の Threads ユーザーネームを検索して追加
5. **スマホの Threads アプリ**で承認: 設定 → アカウント → ウェブサイトのアクセス許可 → 招待を承認
6. 「ユースケース」→ Threads API の「カスタマイズ」→「設定」タブ →「**ユーザートークン生成ツール**」に自分のアカウントが出るので「アクセストークンを生成」→ ポップアップで Threads ログイン → 許可 → **表示された長い文字列（トークン）をコピー**
7. コピーしたトークンを Claude Code のチャットに貼って「これがThreadsのトークン」と伝える

これで人間の作業は終わり。以降は Claude Code が引き継ぐ。

---

## Part B. システム構築（Claude Code への指示）

Claude Code は以下を順に実行すること。

### B-1. プロジェクト作成

作業フォルダに `threads-auto/` を作り、後述の「付録」の3ファイルを配置する:
- `schema.sql`
- `wrangler.toml`（ADMIN_KEY と STOCK_PIN はランダム/本人希望の値に書き換える）
- `src/index.js`（`REWRITE_SYSTEM` 内のプロフィール・トーンを **本人のものに書き換える**。本人に「あなたの発信のトーンの特徴は？」と聞くか、過去投稿を数本もらって抽出する）

### B-2. D1 データベース作成とデプロイ

```bash
cd threads-auto
npx wrangler d1 create threads-auto
# → 出力された database_id を wrangler.toml に反映
npx wrangler r2 bucket create threads-auto-img
npx wrangler d1 execute threads-auto --remote -y --file=schema.sql
npx wrangler deploy
```

デプロイURL（`https://threads-auto.<account>.workers.dev`）を控え、写真投稿用に config へ登録する:

```sql
INSERT INTO config (key, value) VALUES ('base_url','https://<WorkerのURL>')
ON CONFLICT(key) DO UPDATE SET value=excluded.value;
```

### B-3. トークンの登録

本人から受け取ったトークンで、まずアカウント確認:

```bash
curl -s "https://graph.threads.net/v1.0/me?fields=id,username&access_token=<トークン>"
```

`id` と `username` が返ればOK。D1 に登録する（SQLはファイル経由で実行するとWindowsでも文字化けしない）:

```sql
INSERT INTO config (key, value) VALUES
 ('access_token','<トークン>'),
 ('token_updated_at','<今のISO日時>'),
 ('user_id','<idの値>'),
 ('username','<usernameの値>')
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now');
```

### B-4. テスト

1. テスト用の投稿を1件 D1 に入れる（approved=1で）
2. `https://<WorkerのURL>/test-post?key=<ADMIN_KEY>` を叩く
3. `/status?key=<ADMIN_KEY>` で posted_total が増えたこと、実際の Threads に投稿が載ったことを確認
4. 本人にストック棚 `https://<WorkerのURL>/stock`（PIN: 設定した値）を案内。スマホのホーム画面に追加してもらうと便利

### B-5. 投稿ストックの量産（Claude Code の継続業務）

- 形式: `[{"theme":"タイトル","post1":"フック","post2":"続き"}]` の JSON を作り、SQLに変換して D1 へ INSERT（approved は必ず 0 = 下書き。**勝手に承認しない**）
- 1回 10〜15本、週1回の補充が目安（1日3投稿なら週21本消費）
- **品質の鍵は「本人を教えること」**: 本人の過去投稿・話し言葉・価値観・実話をファイルにまとめて、生成時に必ず参照する。テンプレ文ではなく「本人の実感・実体験」から書く
- 角度を混ぜる: ノウハウ型 / 実話・失敗談型 / 思想型 / 問いかけ型 / リスト型 など。反応データを見て 7:3（勝ち筋7・実験3）で配合を調整

---

## Part C. 運用ガイド（本人向け）

- **ストック棚**（/stock・PIN入力）: 下書きを読む → 気になれば編集 or 🎤音声で直す → 「✓これでOK（承認）」。承認したものだけが上から順に投稿される
- **予約日付き投稿**: キャンペーンやカウントダウンは Claude Code に頼むと日付指定で入れてくれる（📅バッジ表示、その日の最初の投稿枠で出る）
- **キャンペーン集中モード**: config の `exclusive_until` に日付を入れると、その日まで予約投稿だけが出る（通常ストックが混ざらない）
- **写真付き投稿**: 棚のカードで「📷写真を付ける」→ スマホから選ぶだけ（自動で圧縮）。付けた投稿だけ1投稿目が写真付きで出る。付けなければ文章のみ
- **反応レポート**: Claude Code に「反応レポート出して」→ 投稿別のビュー・いいね・返信を集計して、どの角度が伸びているか分析してくれる
- **返信が来たら必ず自分で返す**（ここは自動化しない。関係づくりの本体なので）

---

## Part D. ハマりポイント集（実運用で実際に起きたこと）

1. **投稿直後の "Media not found" エラー**: コンテナ作成→公開の間に待ち時間が必要。コードは5秒待ち＋リトライ実装済み
2. **「エラー」と返ってきたのに実は投稿されている**ことがある → リトライ前に直近投稿の本文一致チェックを入れてある（これが無いと同じ投稿が2重に載る。実際に起きた）
3. **Threads の投稿にマークダウン記号（\*\*太字\*\* など）を使わない**。記号がそのまま表示されて「AI感」が出る。生成時・リライト時とも禁止
4. **埋め込みブラウザ・自動化ブラウザからの Threads ログインは弾かれる**。トークン発行は本人の普段のブラウザで
5. **スマホでストック棚の編集が効かない/画面が固まる** → 入力欄の font-size は 16px 必須（iOSが自動ズームして固まる）。textarea は自動で伸びる方式に（内部スクロールが操作を食う）。コードは対応済み
6. **ElevenLabs 等の外部文字起こしAPIは月間枠に注意**（音声素材を扱う場合）
7. **cron は UTC 指定**。JST の時刻から9時間引く（例: 21:00 JST = 12:00 UTC）
8. トークンは60日期限だが、**24時間経過ごとに自動リフレッシュ**するので放置でOK。もし失効したら Part A の手順6でトークンを再発行して B-3 をやり直す
9. **写真付き投稿は「公開URLの画像」しか受け付けない**（Threads API の仕様）。だから R2 に置いて Worker の `/img/` 経由で配信する。画像投稿はコンテナ処理に時間がかかるので初回待ちを10秒にしてある（コード対応済み）。iPhoneのHEIC写真もブラウザ側で JPEG に変換してから送る方式なので大丈夫

---
---

# 付録: ファイル全文

## 付録1: schema.sql

```sql
CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  theme TEXT NOT NULL,
  post1 TEXT NOT NULL,
  post2 TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending', -- pending / posted / error
  approved INTEGER NOT NULL DEFAULT 0,    -- 1=承認済み(投稿対象)
  scheduled_for TEXT,                     -- 'YYYY-MM-DD' 指定日に投稿(任意)
  image_key TEXT,                         -- 写真付き投稿用のR2キー(任意)
  thread_id1 TEXT,
  thread_id2 TEXT,
  posted_at TEXT,
  error TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS config (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```

## 付録2: wrangler.toml

```toml
name = "threads-auto"
main = "src/index.js"
compatibility_date = "2026-08-01"

# 投稿時刻 (UTC指定。例は 朝7:30 / 昼12:30 / 夜21:00 JST)
# 1日1回でよければ ["0 12 * * *"] だけにする
[triggers]
crons = ["30 22 * * *", "30 3 * * *", "0 12 * * *"]

[[d1_databases]]
binding = "DB"
database_name = "threads-auto"
database_id = "<d1 create で出た ID に書き換える>"

# 🎤音声リライト用 (ANTHROPIC_API_KEY secret があれば Claude、なければこの無料AIで動く)
[ai]
binding = "AI"

# 📷投稿写真の保存先
[[r2_buckets]]
binding = "IMG"
bucket_name = "threads-auto-img"

[vars]
ADMIN_KEY = "<ランダムな英数字に書き換える>"
STOCK_PIN = "<本人が覚えやすい4桁以上に書き換える>"

# 任意 secret (wrangler secret put で登録):
#   ANTHROPIC_API_KEY  … 音声リライトをClaude品質にする場合
#   LINE_CHANNEL_TOKEN / LINE_USER_ID … ストック残少をLINE通知する場合
```

## 付録3: src/index.js（全文）

⚠ Claude Code へ: 貼り付け後、`REWRITE_SYSTEM` の中身（発信者プロフィールとトーンルール）を必ず本人のものに書き換えること。

```javascript
/**
 * Threads 毎日自動投稿システム
 * - cron: D1の「承認済み」ストックから1セット取り出し、フック+返信の2連投稿
 * - /stock: 承認制ストック棚(PIN付き)。本人が精査・編集・承認したものだけ投稿される
 * - トークン自動リフレッシュ / 予約日投稿 / キャンペーン集中モード / AIリライト / 写真付き投稿
 */

const GRAPH = "https://graph.threads.net";

export default {
  async scheduled(event, env, ctx) {
    ctx.waitUntil(runDaily(env));
  },

  async fetch(req, env) {
    const url = new URL(req.url);
    const path = url.pathname;

    if (path === "/status") return handleStatus(url, env);
    if (path === "/test-post") return handleTestPost(url, env);
    if (path === "/stock") return stockPage(env);
    if (path.startsWith("/img/")) return serveImage(path, env);
    if (path.startsWith("/api/")) return handleApi(req, url, env);
    return Response.redirect(`${url.origin}/stock`, 302);
  },
};

/* ---------------- 毎日の投稿処理 ---------------- */

async function runDaily(env) {
  try {
    const token = await getFreshToken(env);
    if (!token) {
      await notifyLine(env, "【Threads自動投稿】トークン未設定です。CCに相談してください。");
      return;
    }
    const userId = await getConfig(env, "user_id");

    // 予約日(scheduled_for)が今日の投稿を最優先、なければ通常キューの先頭
    const todayJst = new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10);
    let post = await env.DB.prepare(
      "SELECT * FROM posts WHERE status = 'pending' AND approved = 1 AND scheduled_for = ? ORDER BY id LIMIT 1"
    ).bind(todayJst).first();
    if (!post) {
      // キャンペーン集中モード: exclusive_until の日付までは予約投稿だけを出す
      // (予約が無い/未承認のスロットは黙ってスキップし、通常キューには落ちない)
      const exclusiveUntil = await getConfig(env, "exclusive_until");
      if (exclusiveUntil && todayJst <= exclusiveUntil) {
        console.log("exclusive mode: no scheduled post for this slot, skipping");
        return;
      }
      post = await env.DB.prepare(
        "SELECT * FROM posts WHERE status = 'pending' AND approved = 1 AND scheduled_for IS NULL ORDER BY id LIMIT 1"
      ).first();
    }

    if (!post) {
      await notifyLine(env, "【Threads自動投稿】承認済みのストックが無いため、今日は投稿をお休みしました。ストック棚で承認してください🌙");
      return;
    }

    // 1投稿目 (フック)。写真があれば画像付き投稿にする
    const baseUrl = await getConfig(env, "base_url");
    const imageUrl = post.image_key && baseUrl ? `${baseUrl}/img/${post.image_key}` : null;
    const id1 = await publishText(userId, token, post.post1, null, imageUrl);
    // 2投稿目 (返信)
    await sleep(3000);
    const id2 = await publishText(userId, token, post.post2, id1);

    await env.DB.prepare(
      "UPDATE posts SET status='posted', thread_id1=?, thread_id2=?, posted_at=datetime('now') WHERE id=?"
    ).bind(id1, id2, post.id).run();

    // 承認済み残数チェック
    const { cnt } = await env.DB.prepare(
      "SELECT COUNT(*) AS cnt FROM posts WHERE status='pending' AND approved=1"
    ).first();
    if (cnt <= 7) {
      await notifyLine(env, `【Threads自動投稿】今日も投稿しました✨（${post.theme}）\n承認済みストックは残り ${cnt} 件です。ストック棚で承認を増やすか、CCに「スレッズのストック追加して」と頼んでください。`);
    }
  } catch (e) {
    console.error("daily run failed:", e);
    await env.DB.prepare(
      "UPDATE posts SET status='error', error=? WHERE id = (SELECT id FROM posts WHERE status='pending' AND approved=1 ORDER BY id LIMIT 1)"
    ).bind(String(e)).run().catch(() => {});
    await notifyLine(env, `【Threads自動投稿】今日の投稿でエラーが出ました：${String(e).slice(0, 200)}`);
  }
}

/** 投稿する (replyTo=返信 / imageUrl=画像付き投稿) → 公開済み Thread ID を返す */
async function publishText(userId, token, text, replyTo, imageUrl) {
  const params = new URLSearchParams({
    media_type: imageUrl ? "IMAGE" : "TEXT",
    text,
    access_token: token,
  });
  if (imageUrl) params.set("image_url", imageUrl);
  if (replyTo) params.set("reply_to_id", replyTo);

  const createRes = await fetch(`${GRAPH}/v1.0/${userId}/threads`, {
    method: "POST",
    body: params,
  });
  const created = await createRes.json();
  if (!created.id) throw new Error("container作成失敗: " + JSON.stringify(created));

  // コンテナ生成直後は publish が "Media not found" になることがあるので、待ってからリトライ。
  // ただし「エラー応答なのに実は投稿済み」のケースがあるため、リトライ前に必ず実投稿を確認する
  // (2026-08-27 に重複投稿が実際に発生した対策)
  let lastErr;
  for (let attempt = 0; attempt < 5; attempt++) {
    await sleep(attempt === 0 ? (imageUrl ? 10000 : 5000) : 6000);
    const pubRes = await fetch(`${GRAPH}/v1.0/${userId}/threads_publish`, {
      method: "POST",
      body: new URLSearchParams({ creation_id: created.id, access_token: token }),
    });
    const published = await pubRes.json();
    if (published.id) return published.id;
    lastErr = published;

    const already = await findPublishedByText(userId, token, text);
    if (already) return already;
  }
  const finalCheck = await findPublishedByText(userId, token, text);
  if (finalCheck) return finalCheck;
  throw new Error("publish失敗: " + JSON.stringify(lastErr));
}

/** 直近の投稿から本文一致を探す (publishのエラー応答が嘘だった場合の保険) */
async function findPublishedByText(userId, token, text) {
  try {
    const res = await fetch(
      `${GRAPH}/v1.0/${userId}/threads?fields=id,text&limit=5&access_token=${token}`
    );
    const data = await res.json();
    const hit = (data.data || []).find((p) => p.text === text);
    return hit ? hit.id : null;
  } catch (e) {
    return null;
  }
}

/* ---------------- トークン管理 ---------------- */

async function getFreshToken(env) {
  const token = await getConfig(env, "access_token");
  if (!token) return null;

  const updatedAt = await getConfig(env, "token_updated_at");
  const ageHours = updatedAt
    ? (Date.now() - new Date(updatedAt).getTime()) / 36e5
    : 999;

  // 24時間以上経っていればリフレッシュ (60日期限を毎日延命)
  if (ageHours >= 24) {
    try {
      const res = await fetch(
        `${GRAPH}/refresh_access_token?grant_type=th_refresh_token&access_token=${token}`
      );
      const data = await res.json();
      if (data.access_token) {
        await setConfig(env, "access_token", data.access_token);
        await setConfig(env, "token_updated_at", new Date().toISOString());
        return data.access_token;
      }
      console.error("token refresh failed:", JSON.stringify(data));
    } catch (e) {
      console.error("token refresh error:", e);
    }
  }
  return token;
}

/* ---------------- 状態確認・テスト投稿 ---------------- */

async function handleStatus(url, env) {
  if (url.searchParams.get("key") !== env.ADMIN_KEY) return new Response("forbidden", { status: 403 });

  const approved = await env.DB.prepare("SELECT COUNT(*) AS c FROM posts WHERE status='pending' AND approved=1").first();
  const draft = await env.DB.prepare("SELECT COUNT(*) AS c FROM posts WHERE status='pending' AND approved=0").first();
  const posted = await env.DB.prepare("SELECT COUNT(*) AS c FROM posts WHERE status='posted'").first();
  const last = await env.DB.prepare("SELECT theme, posted_at FROM posts WHERE status='posted' ORDER BY posted_at DESC LIMIT 1").first();
  const username = await getConfig(env, "username");
  const tokenAt = await getConfig(env, "token_updated_at");

  return Response.json({
    account: username || "(未連携)",
    stock_approved: approved.c,
    stock_draft: draft.c,
    posted_total: posted.c,
    last_post: last || null,
    token_refreshed_at: tokenAt || null,
  });
}

async function handleTestPost(url, env) {
  if (url.searchParams.get("key") !== env.ADMIN_KEY) return new Response("forbidden", { status: 403 });
  await runDaily(env);
  return new Response("test post executed — /status で確認してください");
}

/* ---------------- ストック棚 API ---------------- */

async function handleApi(req, url, env) {
  const isPost = req.method === "POST";
  const body = isPost ? await req.json().catch(() => ({})) : {};
  const pin = isPost ? body.pin : url.searchParams.get("pin");
  if (pin !== env.STOCK_PIN) return Response.json({ error: "PINが違います" }, { status: 403 });

  const path = url.pathname;

  if (path === "/api/list") {
    const { results } = await env.DB.prepare(
      "SELECT id, theme, post1, post2, status, approved, posted_at, scheduled_for, image_key FROM posts WHERE status != 'posted' ORDER BY id"
    ).all();
    const { results: recent } = await env.DB.prepare(
      "SELECT id, theme, posted_at FROM posts WHERE status='posted' ORDER BY posted_at DESC LIMIT 5"
    ).all();
    return Response.json({ posts: results, recent });
  }

  if (path === "/api/save" && isPost) {
    await env.DB.prepare(
      "UPDATE posts SET theme=?, post1=?, post2=? WHERE id=? AND status != 'posted'"
    ).bind(body.theme || "", body.post1 || "", body.post2 || "", body.id).run();
    return Response.json({ ok: true });
  }

  if (path === "/api/approve" && isPost) {
    await env.DB.prepare(
      "UPDATE posts SET approved=?, status='pending', error=NULL WHERE id=? AND status != 'posted'"
    ).bind(body.approved ? 1 : 0, body.id).run();
    return Response.json({ ok: true });
  }

  if (path === "/api/delete" && isPost) {
    await env.DB.prepare("DELETE FROM posts WHERE id=? AND status != 'posted'").bind(body.id).run();
    return Response.json({ ok: true });
  }

  if (path === "/api/upload-img" && isPost) {
    try {
      const b64 = (body.data || "").split(",")[1];
      if (!b64) return Response.json({ error: "画像データがありません" }, { status: 400 });
      const bin = atob(b64);
      const bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
      if (bytes.length > 4 * 1024 * 1024) return Response.json({ error: "画像が大きすぎます" }, { status: 400 });
      const key = `${body.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`;
      await env.IMG.put(key, bytes, { httpMetadata: { contentType: "image/jpeg" } });
      // 前の写真があれば消す
      const old = await env.DB.prepare("SELECT image_key FROM posts WHERE id=?").bind(body.id).first();
      if (old && old.image_key) await env.IMG.delete(old.image_key).catch(() => {});
      await env.DB.prepare("UPDATE posts SET image_key=? WHERE id=? AND status != 'posted'").bind(key, body.id).run();
      return Response.json({ ok: true, key });
    } catch (e) {
      return Response.json({ error: "アップロード失敗: " + String(e).slice(0, 100) }, { status: 500 });
    }
  }

  if (path === "/api/remove-img" && isPost) {
    const row = await env.DB.prepare("SELECT image_key FROM posts WHERE id=?").bind(body.id).first();
    if (row && row.image_key) await env.IMG.delete(row.image_key).catch(() => {});
    await env.DB.prepare("UPDATE posts SET image_key=NULL WHERE id=?").bind(body.id).run();
    return Response.json({ ok: true });
  }

  if (path === "/api/rewrite" && isPost) {
    const revised = await rewriteWithAi(env, body);
    if (!revised || revised.__err) return Response.json({ error: "AIの整え直しに失敗しました。もう一度試してください", detail: revised && revised.__err }, { status: 500 });
    return Response.json({ ok: true, ...revised });
  }

  if (path === "/api/approve-all" && isPost) {
    await env.DB.prepare(
      "UPDATE posts SET approved=1, error=NULL WHERE status='pending'"
    ).run();
    return Response.json({ ok: true });
  }

  if (path === "/api/add" && isPost) {
    await env.DB.prepare(
      "INSERT INTO posts (theme, post1, post2, approved) VALUES (?, ?, ?, 0)"
    ).bind(body.theme || "新しい投稿", body.post1 || "", body.post2 || "").run();
    return Response.json({ ok: true });
  }

  return Response.json({ error: "unknown api" }, { status: 404 });
}

/* ---------------- 投稿写真の配信 (Threadsが取得しに来る) ---------------- */

async function serveImage(path, env) {
  const key = path.slice(5);
  if (!key || key.includes("..")) return new Response("not found", { status: 404 });
  const obj = await env.IMG.get(key);
  if (!obj) return new Response("not found", { status: 404 });
  return new Response(obj.body, {
    headers: {
      "content-type": "image/jpeg",
      "cache-control": "public, max-age=31536000",
    },
  });
}

/* ---------------- AIリライト (音声で直す機能) ---------------- */

// ★★★ ここを本人用に書き換える ★★★
// 本人の名前・肩書き・発信トーンの特徴を具体的に書くほど「本人っぽく」直してくれる
const REWRITE_SYSTEM = `あなたは＜発信者の名前・肩書きをここに書く＞のThreads投稿を、本人の音声指示に従って書き直す編集者です。

トーンの絶対ルール:
- ＜本人の話し方の特徴を書く。例: やわらかい「です・ます」の話し言葉＞
- ＜本人がよく使う表現・語尾を書く＞
- ＜NGな言葉・トーンを書く。例: ビジネス用語禁止・煽り禁止＞
- 絵文字は多くても1〜2個。ハッシュタグなし
- マークダウン記号(** や # など)は絶対に使わない
- 本人の実感・実体験ベース。きれいごとに逃げない

形式:
- post1 = 1投稿目(フック)。短く、続きが気になる形。500字以内
- post2 = 2投稿目(返信)。本質と実感を書き、締めの一文まで。500字以内
- 本人の指示に含まれる想い・事実・言い回しを最優先で反映する

出力は次のJSONだけを返す(説明文なし): {"post1":"...","post2":"..."}`;

async function rewriteWithAi(env, body) {
  const userMsg = `【現在の投稿】
テーマ: ${body.theme || ""}
post1:
${body.post1 || ""}

post2:
${body.post2 || ""}

【本人からの直し指示(音声より)】
${body.instruction || ""}

この指示を反映して書き直してください。`;

  let text = null;
  try {
    if (env.ANTHROPIC_API_KEY) {
      const res = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers: {
          "x-api-key": env.ANTHROPIC_API_KEY,
          "anthropic-version": "2023-06-01",
          "content-type": "application/json",
        },
        body: JSON.stringify({
          model: "claude-haiku-4-5-20251001",
          max_tokens: 1500,
          system: REWRITE_SYSTEM,
          messages: [{ role: "user", content: userMsg }],
        }),
      });
      const data = await res.json();
      text = data.content && data.content[0] && data.content[0].text;
    } else {
      const result = await env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
        messages: [
          { role: "system", content: REWRITE_SYSTEM },
          { role: "user", content: userMsg },
        ],
        max_tokens: 1500,
      });
      if (typeof result === "string") text = result;
      else if (typeof result.response === "string") text = result.response;
      else if (result.response) text = JSON.stringify(result.response);
      else return { __err: "unexpected AI result: " + JSON.stringify(result).slice(0, 250) };
    }
    if (!text) return { __err: "empty response" };
    const match = text.match(/\{[\s\S]*\}/);
    if (!match) return { __err: "no json: " + String(text).slice(0, 200) };
    const parsed = JSON.parse(match[0]);
    if (!parsed.post1 || !parsed.post2) return null;
    // 念のためマークダウン記号を除去
    parsed.post1 = parsed.post1.replace(/\*\*/g, "").slice(0, 500);
    parsed.post2 = parsed.post2.replace(/\*\*/g, "").slice(0, 500);
    return parsed;
  } catch (e) {
    console.error("rewrite failed:", e);
    return { __err: String(e).slice(0, 300) };
  }
}

/* ---------------- ストック棚 UI ---------------- */

function stockPage(env) {
  const html = `<!doctype html><html lang="ja"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Threads ストック棚</title>
<style>
  :root { --rose:#b48a78; --rose-d:#8f6a5a; --bg:#fdfbf9; --card:#fff; --line:#eadfd8; --ok:#7fae8f; --ok-d:#5e8f70; }
  * { box-sizing:border-box; }
  body { font-family:-apple-system,"Hiragino Sans","Noto Sans JP",sans-serif; background:var(--bg); color:#4a3f3a; margin:0; padding:16px; max-width:640px; margin-inline:auto; }
  h1 { font-size:1.15em; color:var(--rose-d); margin:8px 0 2px; }
  .sub { font-size:.8em; color:#a08d83; margin-bottom:14px; }
  .counts { display:flex; gap:8px; margin-bottom:14px; flex-wrap:wrap; }
  .pill { font-size:.78em; padding:4px 10px; border-radius:999px; background:#f4ece8; color:var(--rose-d); }
  .pill.ok { background:#e8f2ec; color:var(--ok-d); }
  .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin-bottom:14px; box-shadow:0 1px 4px rgba(180,138,120,.08); }
  .card.approved { border-color:var(--ok); }
  .card.next { box-shadow:0 0 0 2px var(--ok); }
  .head { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
  .badge { font-size:.7em; padding:2px 8px; border-radius:999px; background:#f4ece8; color:var(--rose-d); white-space:nowrap; }
  .badge.ok { background:var(--ok); color:#fff; }
  .badge.next { background:var(--ok-d); color:#fff; }
  input.theme { flex:1; border:none; font-weight:600; font-size:16px; color:#4a3f3a; background:transparent; min-width:0; }
  label { font-size:.72em; color:#a08d83; display:block; margin:8px 0 3px; }
  textarea { width:100%; border:1px solid var(--line); border-radius:10px; padding:9px; font-size:16px; line-height:1.6; font-family:inherit; resize:none; overflow:hidden; background:#fffdfc; }
  .cnt { font-size:.68em; color:#c0b0a8; text-align:right; }
  .cnt.over { color:#d06060; font-weight:700; }
  .btns { display:flex; gap:8px; margin-top:10px; }
  button { border:none; border-radius:10px; padding:9px 14px; font-size:.82em; font-weight:600; cursor:pointer; font-family:inherit; }
  .b-approve { background:var(--ok); color:#fff; flex:1; }
  .b-unapprove { background:#f0e6e0; color:var(--rose-d); flex:1; }
  .b-save { background:var(--rose); color:#fff; }
  .b-del { background:transparent; color:#c9958a; }
  .b-add { width:100%; background:#fff; border:2px dashed var(--line); color:var(--rose-d); padding:14px; border-radius:14px; margin-bottom:10px; }
  .b-bulk { width:100%; background:#e8f2ec; color:var(--ok-d); padding:12px; border-radius:14px; margin-bottom:20px; }
  .b-voice { width:100%; background:#f3eefa; color:#7a5fa0; margin-top:8px; padding:10px; border-radius:10px; }
  .b-photo { width:100%; background:#eef4f8; color:#5a7e9e; margin-top:8px; padding:10px; border-radius:10px; }
  .photoRow { display:flex; align-items:center; gap:10px; margin-top:8px; }
  .thumb { width:88px; height:88px; object-fit:cover; border-radius:10px; border:1px solid var(--line); }
  .photoNote { font-size:.7em; color:#a08d83; flex:1; }
  .voicePanel { display:none; background:#faf7fd; border:1px solid #e2d8ef; border-radius:12px; padding:10px; margin-top:8px; }
  .voicePanel.show { display:block; }
  .voicePanel textarea { background:#fff; border-color:#e2d8ef; }
  .voiceBtns { display:flex; gap:8px; margin-top:8px; }
  .b-mic { background:#fff; border:1px solid #d8c8ee; color:#7a5fa0; }
  .b-mic.rec { background:#d06060; color:#fff; border-color:#d06060; }
  .b-ai { background:#7a5fa0; color:#fff; flex:1; }
  .b-ai:disabled { opacity:.5; }
  .voiceHint { font-size:.68em; color:#a08d83; margin-top:6px; line-height:1.5; }
  .toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%); background:#4a3f3a; color:#fff; font-size:.8em; padding:9px 18px; border-radius:999px; opacity:0; transition:.3s; pointer-events:none; }
  .toast.show { opacity:1; }
  .recent { font-size:.75em; color:#a08d83; margin-top:20px; padding-top:12px; border-top:1px solid var(--line); }
  #pinGate { position:fixed; inset:0; background:var(--bg); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; z-index:10; }
  #pinGate input { font-size:1.4em; text-align:center; letter-spacing:.4em; width:9em; padding:10px; border:1px solid var(--line); border-radius:12px; }
  #pinGate .err { color:#d06060; font-size:.8em; min-height:1em; }
</style></head><body>
<div id="pinGate">
  <div style="font-size:2em">🪷</div>
  <div style="color:var(--rose-d);font-weight:600">Threads ストック棚</div>
  <input id="pinInput" type="password" inputmode="numeric" placeholder="PIN" autocomplete="off">
  <div class="err" id="pinErr"></div>
</div>
<h1>🪷 Threads ストック棚</h1>
<div class="sub">承認した投稿だけが、毎日、上から順に自動投稿されます。文章は自由に手直しOK。</div>
<div class="counts" id="counts"></div>
<button class="b-add" onclick="addCard()">＋ 新しい投稿を書く</button>
<button class="b-bulk" onclick="approveAll()">✓✓ 下書きをまとめて承認する</button>
<div id="list"></div>
<div class="recent" id="recent"></div>
<div class="toast" id="toast"></div>
<script>
let PIN = localStorage.getItem("threadsPin") || "";
const $ = (id) => document.getElementById(id);

async function api(path, data) {
  try {
    const res = await fetch("/api/" + path, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ pin: PIN, ...data }),
    });
    const d = await res.json();
    if (!res.ok || d.error) return { ok: false, error: d.error || "エラー" };
    return { ok: true, ...d };
  } catch (e) {
    return { ok: false, error: "通信エラー" };
  }
}

function toast(msg) {
  const t = $("toast");
  t.textContent = msg;
  t.classList.add("show");
  setTimeout(() => t.classList.remove("show"), 1800);
}

function esc(s) {
  return (s || "").replace(/[&<>"]/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m]));
}

function counter(ta) {
  const n = [...ta.value].length;
  const el = ta.nextElementSibling;
  el.textContent = n + " / 500文字";
  el.className = "cnt" + (n > 500 ? " over" : "");
  ta.style.height = "auto";
  ta.style.height = ta.scrollHeight + 4 + "px";
}

async function load() {
  const res = await fetch("/api/list?pin=" + encodeURIComponent(PIN));
  if (res.status === 403) { localStorage.removeItem("threadsPin"); PIN = ""; gate(true, "PINが違います"); return; }
  gate(false);
  const data = await res.json();
  const posts = data.posts || [];
  const ok = posts.filter((p) => p.approved).length;
  const draft = posts.length - ok;
  $("counts").innerHTML =
    '<span class="pill ok">✓ 承認済み ' + ok + '件 (' + ok + '日分)</span>' +
    '<span class="pill">下書き ' + draft + '件</span>';
  const todayJst = new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10);
  const scheduledToday = posts.find((p) => p.approved && p.scheduled_for === todayJst);
  let nextMarked = !!scheduledToday;
  $("list").innerHTML = posts.map((p) => {
    const isNext = (scheduledToday && p.id === scheduledToday.id) || (p.approved && !p.scheduled_for && !nextMarked && (nextMarked = true));
    const schedBadge = p.scheduled_for ? '<span class="badge" style="background:#e8ecf5;color:#5a6e9e">📅 ' + p.scheduled_for.slice(5).replace('-', '/') + '投稿</span>' : '';
    return '<div class="card' + (p.approved ? " approved" : "") + (isNext ? " next" : "") + '" id="card' + p.id + '">' +
      '<div class="head">' +
        (isNext ? '<span class="badge next">次これ</span>' : p.approved ? '<span class="badge ok">✓ 承認済み</span>' : '<span class="badge">下書き</span>') + schedBadge +
        '<input class="theme" id="t' + p.id + '" value="' + esc(p.theme) + '">' +
      '</div>' +
      '<label>1投稿目（フック）</label>' +
      '<textarea id="a' + p.id + '" rows="4" oninput="counter(this)">' + esc(p.post1) + '</textarea><div class="cnt"></div>' +
      '<label>2投稿目（返信）</label>' +
      '<textarea id="b' + p.id + '" rows="7" oninput="counter(this)">' + esc(p.post2) + '</textarea><div class="cnt"></div>' +
      '<div class="btns">' +
        (p.approved
          ? '<button class="b-unapprove" onclick="approve(' + p.id + ',0)">承認を外す</button>'
          : '<button class="b-approve" onclick="approve(' + p.id + ',1)">✓ これでOK（承認）</button>') +
        '<button class="b-save" onclick="save(' + p.id + ')">保存</button>' +
        '<button class="b-del" onclick="del(' + p.id + ')">削除</button>' +
      '</div>' +
      (p.image_key
        ? '<div class="photoRow"><img class="thumb" src="/img/' + p.image_key + '"><span class="photoNote">この写真が1投稿目に付きます</span><button class="b-del" onclick="removePhoto(' + p.id + ')">写真を外す</button></div>'
        : '<button class="b-photo" onclick="pickPhoto(' + p.id + ')">📷 写真を付ける（任意・1投稿目に付きます）</button>') +
      '<input type="file" id="file' + p.id + '" accept="image/*" style="display:none" onchange="onPhoto(' + p.id + ', this)">' +
      '<button class="b-voice" onclick="toggleVoice(' + p.id + ')">🎤 音声で直す（想いを話すとAIが整えます）</button>' +
      '<div class="voicePanel" id="vp' + p.id + '">' +
        '<label>どう直したい？どんな想いを込めたい？</label>' +
        '<textarea id="vi' + p.id + '" rows="3" placeholder="例: もっと生徒さんへの感謝を強めに。最初の一文はやわらかく…（キーボードのマイク🎤で話してOK）"></textarea>' +
        '<div class="voiceBtns">' +
          '<button class="b-mic" id="mic' + p.id + '" onclick="toggleMic(' + p.id + ')">🎤 話す</button>' +
          '<button class="b-ai" id="ai' + p.id + '" onclick="aiRewrite(' + p.id + ')">✨ AIに整えてもらう</button>' +
        '</div>' +
        '<div class="voiceHint">整った文章は上の欄に入ります。読んで気に入ったら「保存」、そのまま出すなら「承認」まで押してください。</div>' +
      '</div></div>';
  }).join("");
  document.querySelectorAll("textarea").forEach(counter);
  const rec = data.recent || [];
  $("recent").innerHTML = rec.length
    ? "🕊 投稿済み：" + rec.map((r) => esc(r.theme) + "（" + (r.posted_at || "").slice(5, 10) + "）").join(" / ")
    : "";
}

async function save(id) {
  const r = await api("save", { id, theme: $("t" + id).value, post1: $("a" + id).value, post2: $("b" + id).value });
  toast(r.ok ? "保存しました" : "⚠️ 保存できませんでした（" + r.error + "）もう一度押してください");
}

async function approve(id, val) {
  const r1 = await api("save", { id, theme: $("t" + id).value, post1: $("a" + id).value, post2: $("b" + id).value });
  const r2 = await api("approve", { id, approved: val });
  if (!r1.ok || !r2.ok) { toast("⚠️ うまくいきませんでした。もう一度押してください"); return; }
  toast(val ? "承認しました✓ 投稿予約に入りました" : "承認を外しました");
  load();
}

async function del(id) {
  if (!confirm("この投稿を削除しますか？")) return;
  const r = await api("delete", { id });
  toast(r.ok ? "削除しました" : "⚠️ 削除できませんでした。もう一度押してください");
  load();
}

async function approveAll() {
  if (!confirm("下書きをぜんぶ承認して投稿予約に入れますか？\\n（あとから個別に「承認を外す」こともできます）")) return;
  await api("approve-all", {});
  toast("まとめて承認しました✓");
  load();
}

async function addCard() {
  await api("add", { theme: "新しい投稿", post1: "", post2: "" });
  toast("追加しました。内容を書いて承認してください");
  load();
}

function toggleVoice(id) {
  $("vp" + id).classList.toggle("show");
}

function pickPhoto(id) {
  $("file" + id).click();
}

async function onPhoto(id, input) {
  const f = input.files[0];
  if (!f) return;
  toast("写真を準備中…");
  try {
    const img = new Image();
    const url = URL.createObjectURL(f);
    await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; });
    const MAX = 1440;
    const sc = Math.min(1, MAX / Math.max(img.width, img.height));
    const cv = document.createElement("canvas");
    cv.width = Math.round(img.width * sc);
    cv.height = Math.round(img.height * sc);
    cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height);
    const data = cv.toDataURL("image/jpeg", 0.85);
    URL.revokeObjectURL(url);
    const r = await api("upload-img", { id, data });
    if (!r.ok) { toast("⚠️ 写真を付けられませんでした（" + r.error + "）"); return; }
    toast("写真を付けました📷");
    load();
  } catch (e) {
    toast("⚠️ この写真は読み込めませんでした。スクショか別の写真で試してください");
  }
  input.value = "";
}

async function removePhoto(id) {
  const r = await api("remove-img", { id });
  toast(r.ok ? "写真を外しました" : "⚠️ うまくいきませんでした。もう一度どうぞ");
  load();
}

let recog = null, recTarget = null;
function toggleMic(id) {
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  if (!SR) { toast("この端末はキーボードのマイク🎤で音声入力してください"); $("vi" + id).focus(); return; }
  if (recog) { recog.stop(); return; }
  recog = new SR();
  recog.lang = "ja-JP";
  recog.continuous = true;
  recog.interimResults = false;
  recTarget = id;
  recog.onresult = (e) => {
    let t = "";
    for (let i = e.resultIndex; i < e.results.length; i++) t += e.results[i][0].transcript;
    const ta = $("vi" + recTarget);
    ta.value = (ta.value ? ta.value + " " : "") + t;
  };
  recog.onend = () => { $("mic" + recTarget).classList.remove("rec"); $("mic" + recTarget).textContent = "🎤 話す"; recog = null; };
  recog.onerror = () => { toast("マイクが使えませんでした。キーボードの🎤で入力してください"); };
  recog.start();
  $("mic" + id).classList.add("rec");
  $("mic" + id).textContent = "⏹ 停止";
}

async function aiRewrite(id) {
  const instruction = $("vi" + id).value.trim();
  if (!instruction) { toast("直したいことを話して（書いて）ください"); return; }
  const btn = $("ai" + id);
  btn.disabled = true; btn.textContent = "✨ 整えています…";
  const r = await api("rewrite", {
    id, instruction,
    theme: $("t" + id).value, post1: $("a" + id).value, post2: $("b" + id).value,
  });
  btn.disabled = false; btn.textContent = "✨ AIに整えてもらう";
  if (!r.ok) { toast("⚠️ " + (r.error || "うまくいきませんでした。もう一度どうぞ")); return; }
  $("a" + id).value = r.post1;
  $("b" + id).value = r.post2;
  counter($("a" + id)); counter($("b" + id));
  toast("整えました✨ 読んで良ければ「保存」してください");
}

function gate(show, err) {
  $("pinGate").style.display = show ? "flex" : "none";
  $("pinErr").textContent = err || "";
  if (show) $("pinInput").focus();
}

$("pinInput").addEventListener("input", (e) => {
  if (e.target.value.length >= 4) {
    PIN = e.target.value;
    localStorage.setItem("threadsPin", PIN);
    e.target.value = "";
    load();
  }
});

if (PIN) load(); else gate(true);
</script></body></html>`;
  return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
}

/* ---------------- LINE 通知 (任意設定) ---------------- */

async function notifyLine(env, message) {
  if (!env.LINE_CHANNEL_TOKEN || !env.LINE_USER_ID) return;
  try {
    await fetch("https://api.line.me/v2/bot/message/push", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${env.LINE_CHANNEL_TOKEN}`,
      },
      body: JSON.stringify({
        to: env.LINE_USER_ID,
        messages: [{ type: "text", text: message }],
      }),
    });
  } catch (e) {
    console.error("LINE notify failed:", e);
  }
}

/* ---------------- 共通 ---------------- */

async function getConfig(env, key) {
  const row = await env.DB.prepare("SELECT value FROM config WHERE key=?").bind(key).first();
  return row ? row.value : null;
}

async function setConfig(env, key, value) {
  await env.DB.prepare(
    "INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')"
  ).bind(key, value).run();
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

function htmlPage(title, body) {
  return new Response(
    `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
     <style>body{font-family:sans-serif;max-width:480px;margin:40px auto;padding:0 20px;color:#4a3f3a;background:#fdfbf9}h1{font-size:1.3em;color:#b48a78}pre{white-space:pre-wrap;background:#f4ece8;padding:12px;border-radius:8px;font-size:.8em}</style>
     </head><body><h1>${title}</h1>${body}</body></html>`,
    { headers: { "content-type": "text/html; charset=utf-8" } }
  );
}
```

---

## 最後に（渡す人へ）

このシステムの本体は、コードよりも「**AIに自分を教えること**」です。
自分の過去投稿・話し言葉・価値観・実話を Claude Code に渡してから投稿を作らせると、
「AIっぽい文章」ではなく「自分の言葉」が量産されるようになります。
コードはこの指示書で1日で動きます。そこから先の差は、AIにどれだけ自分を教えたか、です。

（この指示書は 後藤あい / 株式会社Lavique の実運用システムをもとに作成しました）
