1
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?

Next.js 15 + FastAPI + PostgreSQLで自作Qiita記事半自動生成ツールをVPS本番化するまでに踏んだ罠まとめ

1
Last updated at Posted at 2026-05-26

はじめに

「Claude Codeで作ったツールのログを、そのツール自身でQiita記事に変換する」

このメタ循環を実現するツール qiitto を1週間で本番化しました。本記事もqiitto自身で生成・同期しています。

qiittoはClaude Code開発ログ(やGit diff・メモ)を素材としてAnthropicのClaude APIに投げ、Qiita向けMarkdown記事を半自動生成するWebアプリです。「半自動」というのが肝で、必ず人間レビューを介す設計を構造レベルで強制しています。Qiitaのスパム規約に抵触しないよう、自動公開機能は意図的に作りませんでした。

本記事では技術スタックと設計判断の理由、そして本番化で踏んだ実装上の罠を中心にまとめます。


やったこと

技術スタック

レイヤー 採用技術
フロントエンド Next.js 15 (App Router) / TypeScript / Tailwind CSS v3
バックエンド Python 3 / FastAPI / SQLAlchemy 2.0 async / asyncpg / Alembic
DB PostgreSQL 17.9(既存VPSに同居)
認証 自前JWT(HS256)+ passlib(argon2) + python-jose
暗号化 cryptography Fernet(APIキー保存用)
外部API Anthropic Claude API(claude-sonnet-4-6)/ Qiita API v2
インフラ ConoHa VPS / Nginx / systemd / PM2
エディタ @uiw/react-md-editor(SSR無効の動的import)

DBは5テーブル構成

users          … 自前認証(email + argon2ハッシュ)
sources        … 取込素材(claude_log / git_diff / memo)
generations    … AI生成履歴(status, tokens_used など)
drafts         … Qiita下書き(qiita_item_id, qiita_url, qiita_status)
user_settings  … APIキー暗号化済み+設定

なぜSupabaseを使わなかったか

最初はSupabaseで計画していましたが、以下の理由で「既存VPSのPostgreSQL + 自前JWT」に切り替えました。

  • 既存VPSに他の自社プロダクトが同居しており、PGロールを追加するだけでコスト0
  • 1ユーザー前提なのでJWT認証をシンプルに実装できる
  • 外部サービス依存ゼロで将来別プロダクトに使い回せる

UserScopedRepositoryによる認可境界の強制

RLS(Row Level Security)の代わりに、アプリ層で user_id = current_user.id のWHEREを必ず付ける設計を採用しました。開発者が忘れないよう、生selectを書くと型エラーになる構造にしています。

class UserScopedRepository:
    def query(self, session, user_id):
        return select(self.model).where(self.model.user_id == user_id)

APIキーのFernet暗号化保存

Qiita PAT・Anthropic API Keyはuser_settingsテーブルにFernetで暗号化して保存。フロントエンドには復号値を一切返さず、マスク表示のみとし、psqlで平文検索しても0件になることを確認しています。

# DBに保存される値の例
qiita_pat_enc = "gAAAAAB...(base64文字列)"

生成パイプラインとプロンプト設計

Claudeへのプロンプトはレスポンスをパースしやすくするため、3セクション固定フォーマットで回答させています。

TITLE_OPTIONS:  … タイトル候補
SUGGESTED_TAGS: … タグ候補
ARTICLE_BODY:   … 本文Markdown

Claudeが説明文を前置きする可能性に備え、ARTICLE_BODY: が見つからない場合は本文全文をフォールバック保存するパーサも実装。実測でsonnet-4-6は本文5,100字・タグ5個・トークン3,614で生成でき、コスト換算で1記事5〜8円程度です。


ハマったポイント

1. Next.js standalone + リバースプロキシのリダイレクト罠

本番アクセス時に https://qiitto.sns-tool.online/ を開くと https://localhost:3110/login?redirect=%2F` にリダイレクトされる症状が発生しました。

原因の切り分け

  • フロントエンドのソースを grep "localhost" で全件洗っても、本番未使用の next.config.js の rewrite fallback 1箇所のみ
  • .env.productionNEXT_PUBLIC_API_BASE_URL=/api を明記しても変わらず
  • 決定的証拠:Nginxを経由せず Next.js を直叩き(Host: qiitto.sns-tool.online ヘッダ付き)しても Location: https://localhost:3110/login が返る

真因

リバースプロキシ配下のNext.js standaloneモードでは、request.nextUrl の originが内部バインド(localhost:PORT) になります。middleware の nextUrl.clone() がそれを引き継ぐため、リダイレクトURLにlocalhostが混入していました。

修正

X-Forwarded-Proto / Host ヘッダ起点で外部URLを組み立てるヘルパー externalUrl() を実装し、middlewareのリダイレクトURL構築に使うように変更しました。

function externalUrl(
  request: NextRequest,
  pathname: string,
  params?: Record<string, string>
) {
  const proto = request.headers.get('x-forwarded-proto') || 'https'
  const host = request.headers.get('host') || request.nextUrl.host
  const url = new URL(pathname, `${proto}://${host}`)
  if (params) {
    for (const [k, v] of Object.entries(params)) {
      url.searchParams.set(k, v)
    }
  }
  return url
}

修正後の検証結果:

/ → 307 → https://qiitto.sns-tool.online/login?redirect=%2F     ✅
/drafts  → https://qiitto.sns-tool.online/login?redirect=%2Fdrafts ✅
Next直叩き(Host付き) → localhost 消滅               ✅

Next.js公式ドキュメントには記載のない罠です。リバースプロキシ + standaloneモードの構成では必ずこのパターンを使うようにしてください。


2. Qiita API PATCHはfull payload必須(公式に書いていない)

unpublish時に {"private": true} だけ送ると、Qiita APIは 400を返しますtitle body tags も含めたフルペイロードが必要です。

# NG(400が返る)
await client.patch(f"/items/{item_id}", json={"private": True})

# OK(200)
await client.patch(f"/items/{item_id}", json={
    "title": draft.title,
    "body": draft.body,
    "tags": draft.tags,
    "private": True,
})

これはQiita APIの公式ドキュメントには明記されていない仕様です。


3. Qiitaタグに日本語は使えない

日本語タグ(例:設計)を含む下書きを同期しようとすると Qiita が 422 を返します。

{"message": "次のタグを修正してください: 設計"}

タグは英数字・ドット・ハイフン・アンダースコアのみ有効です。入力時に正規表現 ^[a-zA-Z0-9._-]+$ でバリデーションし、不正なタグを赤ハイライト+警告表示するUIを追加しました。


4. VPS同居デプロイで設計書と実態がズレていた

既存の14本のPM2サービスが稼働するVPSに新サービスを追加する際、設計書の値が実態と食い違っていた箇所が5つありました。

設計書の想定 実際の本番値 理由
backendポート 8100 8110 8100は既存サービスが使用中
frontendポート 3100 3110 3100は別PM2アプリが使用中
実行ユーザー cotton root cottonユーザー不在、既存全アプリがroot運用
PostgreSQL 16 17.9 VPS導入済みを流用
SSH鍵名 conoha_key id_ed25519 実態の鍵名に合わせる

これらはClaude Codeがコマンド(pm2 listss -tlnpなど)で実機を読み取り専用で調査して発見・提案してくれました。「設計書通りに突き進まず、本番実態を尊重する」判断が既存サービスへのゼロ事故を実現しています。


5. 403ではなく404を返す情報隠蔽設計

他人のリソースへのアクセスに対して 403 を返すと「そのリソースは存在する」という情報が漏れます。qiittoでは他人のリソースには必ず404を返す設計にし、以下の4攻撃シナリオを自動テストで保証しています。

他人の draft_id への GET    → 404(存在を漏らさない)
他人の draft_id への PATCH  → 404、本人データ不変
他人の draft_id への DELETE → 404
未認証アクセス              → 401

学び

(以下は個人の感想です)

  • 設計書より本番実態を尊重したほうが事故が減る:ユーザーやポートを設計書に合わせようとせず、現実に合わせたことで既存サービスへの影響ゼロを達成できました。
  • AIエージェント駆動開発でも、認可境界・暗号化・情報隠蔽などの地味な堅実さは人間が最初に設計に組み込む必要があるUserScopedRepository を設計段階で強制する構造にしておいたのが効きました。
  • Next.js standalone + リバプロのリダイレクト罠は公式に書いていない:実機で踏まないと気づけません。X-Forwarded-Proto / Host ヘッダ起点でURLを組み立てるパターンは覚えておく価値があります。
  • Qiita API の PATCH は full payload 必須:これも公式に書かれていない仕様です。
  • 「自分のツールで自分のブログを書く」というメタ循環には妙な達成感がある:本記事もqiitto自身で生成・同期しています。

おわりに

qiittoはLP( https://qiitto-lp.sns-tool.online )でコンセプトを公開しています。アプリ本体はログイン必須のシングルユーザー運用ですが、設計やコードで参考になる部分があれば嬉しいです。

v2のロードマップとしては、非同期ジョブ化・GitHub PR/コミット履歴からの自動取込・posuttoとの連携(公開時にSNSへ告知)などを考えています。

本記事はシリーズ「qiitto 開発記録」の第1弾です。
次回以降では、UserScopedRepositoryで認可漏れを構造的に防ぐ設計の詳細や、
AnthropicのClaude APIを使った半自動コンテンツ生成のプロンプト設計など、
個別のトピックを深掘りしていく予定です。

Cotton-Web 屋号で運営している自社プロダクト群の5本目です:posutto / tubetto / tradepostpro / keiri / qiitto

1
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
1
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?