サイトURL: https://onigirikl.jp
TL;DR
- Next.js 14(App Router)でフロントとAPIを同居させ、OpenAI + Supabase(pgvector)でES生成と模擬面接を実装した
- 企業情報のスクレイピングはSSRF対策・robots.txt対応・リダイレクト制限を入れて堅牢化
- LLMはJSONモード + Zod検証で構造化し、ES生成→質問生成→脚注付与→品質検査まで多段パイプライン化
- Stripeのクレジット課金 + 無料トライアル(SetupIntent)で不正利用を抑止
- 音声面接はWhisperで文字起こし、Google Cloud TTSで面接官音声を合成
1. 背景と課題
就活のES(エントリーシート)は「企業の求める人物像」と「自分のエピソード」の整合性が最重要ですが、
- 企業の公式情報を調べる工数
- ESの構造化(STAR形式)
- 面接想定問答
の3点が学生にとって高いボトルネックになります。
そこで、企業情報の自動収集 + LLMによる生成 + 面接練習まで一気通貫で支援するサービスを実装しました。
2. 全体アーキテクチャ
App Routerを採用し、画面とAPIを同一リポジトリで完結させています。
[Client (Next.js)]
│ (Auth / Profile / Draft / Interview)
│
├─▶ /api/credits/consume ── クレジット消費 + Draft作成
├─▶ /api/scrape ── 企業情報スクレイピング
├─▶ /api/generate ── ES生成 + QA + Footnotes
├─▶ /api/interview/session ── 面接セッション開始
└─▶ /api/interview/.../answer ── 音声回答 -> 文字起こし -> 次質問
[LLM Service] OpenAI
├─ Embedding ──▶ pgvector
├─ ES生成
├─ QA生成
├─ Footnote生成
└─ 面接質問/評価
[DB] Supabase(PostgreSQL + pgvector)
技術スタック
- Next.js 14(App Router): Server ComponentsとRoute Handlersで統一
- TypeScript + Tailwind + shadcn/ui: UI/UXの高速実装
- Supabase(PostgreSQL + pgvector): RAGとRLSを両立
- OpenAI: GPT-4o mini, text-embedding-3-small
- Stripe: 課金 + SetupIntentで無料枠の乱用防止
- Upstash Redis: Rate Limit
- PostHog: イベント分析
- Google Cloud TTS: 面接官音声
3. データモデリング
ES生成・面接・課金を統合するため、以下のようなスキーマを設計しました。
User 1─1 Profile
User 1─N Draft
Company 1─N CompanyDoc
Draft N─1 Company
Draft 1─N InterviewSession 1─N InterviewTurn
User 1─N Plan
User 1─N Payment
User 1─N CreditUsage
特に重要なのは CompanyDoc / Profileのembedding カラムです。Prismaではvector型を直接扱えないため、
$executeRaw / $queryRawUnsafe で挿入・検索を行っています。
SELECT
id, title, summary, url,
1 - (embedding <=> $1::vector) as similarity
FROM "CompanyDoc"
WHERE "companyId" = $2
ORDER BY embedding <=> $1::vector
LIMIT $3
4. 認証・セキュリティ
認証フロー
- NextAuth.js(Prisma Adapter)
- Credentialsログイン + Google OAuth
- OTP(8桁) をメール送信して二段階認証
if (!/^(\d{8})$/.test(otp)) throw new Error('OTP_INVALID')
const verification = await verifyTwoFactorToken(user.id, otp)
セキュリティ対策
- RLS(Row Level Security) を全ユーザ系テーブルに付与
- CSPをmiddlewareで付与し、nonceを埋め込む
- SSRF対策 + robots.txt遵守
- ZodによるAPI入力検証
- Upstash RedisでRate Limit
5. 企業情報のスクレイピング
5-1. URL検索
企業名から採用ページを探すために、OpenAI Web Search → Serper → Google CSEの順でフォールバックしています。
const query = `${companyName} 採用情報 求人 新卒 中途`;
const response = await client.responses.create({ tools: [{ type: 'web_search_preview' }], ... })
5-2. SSRF対策 + robots.txt
スクレイピングはセキュリティリスクが高いため、以下を実装しています。
- DNS解決後に プライベートIPを拒否
- リダイレクト最大5回
- robots.txtを取得してDisallow判定
- 10秒タイムアウト
if (this.isPrivateAddress(record.address)) {
throw new Error('URL resolves to a private network address')
}
5-3. 要約 + Embedding
取得したHTMLは Readability(jsdom)で本文抽出し、200字程度に要約 → embedding生成 → pgvectorに格納。
const summary = await llm.generateText(summaryPrompt, { maxTokens: 500 })
const embedding = await llm.createEmbedding(summary)
6. RAGパイプライン
ES生成の信頼性を上げるため、企業情報の文書をRAGで利用。
- プロフィール要約 → embedding生成
-
CompanyDocから類似文書を検索 - 文書群を context としてLLMに渡す
const documents = await rag.searchSimilarDocuments(profileSummary, companyId, 5)
const context = documents.map(doc => `Title: ${doc.title}\nSummary: ${doc.summary}`).join('\n---\n')
7. ES生成の多段パイプライン
LLM出力は「1回で全て生成」ではなく、多段に分解しています。
- マッチング評価(適合度・ギャップ)
- ES生成(STAR形式、セクション別)
- 面接質問生成
- 脚注生成
- 品質チェック
const evaluation = await llm.evaluate(profileSummary, ragResult.context, episodes)
const es = await llm.generateES(evaluation, companyName, ragResult.context, episodes, sections)
const qa = await llm.generateQA(es, profileSummary, companyName)
const footnotes = await llm.generateFootnotes(es, ragResult.documents, sections)
const checks = await llm.checkQuality(es)
JSONモード + Zod検証
LLMは常に JSON形式で返却し、Zodでスキーマ検証。
const response = await provider.generate(prompt, { responseFormat: 'json_object' })
const parsed = JSON.parse(response)
return EvaluationSchema.parse(parsed)
これにより、LLMが不正な構造を返した場合は即エラーにでき、安定性が向上します。
8. Draftステータス設計
生成は非同期で失敗しうるため、Draftを状態遷移で管理。
PENDING -> SCRAPING -> READY -> GENERATING -> COMPLETED
└-> SCRAPE_FAILED
GENERATING -> GENERATION_FAILED
失敗時にはDraftにエラーメッセージを保存し、UI側で再試行可能にしています。
9. 再生成・編集UI
ESは必ずしも一発でベストにならないため、
トーン・抽象度・数値密度など調整パラメータを持つ再生成APIを実装しています。
const regenerated = await llm.regenerateSection(draft.es, section, adjustments)
フロント側ではSliderで操作し、LocalStorageで再生成回数を制御。
10. 模擬面接(音声対応)
10-1. セッション制御
- ESから生成した予測質問を必須質問として扱う
- opening質問を固定で挿入
- follow-up回数の最小/最大を環境変数で制御
10-2. 音声→文字起こし
Whisper(gpt-4o-mini-transcribe)を使い、
「締めの挨拶や定型句を補完しない」プロンプトで歪みを抑制。
const response = await this.client.audio.transcriptions.create({
model: 'gpt-4o-mini-transcribe',
prompt: '日本語の面接回答です。存在しない締めの挨拶を補わないでください。',
})
10-3. 面接官音声
Google Cloud TTSを利用。
音声データはBase64でフロントに返却して即再生。
11. 課金 + 無料トライアル設計
Stripe Checkout
- ONEOFF(1社)
- BUNDLE(3社)
- INTERVIEW(面接枠)
Webhookで支払い完了時にクレジット付与。
無料トライアルの不正防止
- Stripe SetupIntentでカード検証のみ実行
- 端末指紋(HMAC) + カードfingerprint + Cookie で再利用を防止
- IPレート制限
const fingerprintHash = createHmac('sha256', salt).update(rawFingerprint).digest('hex')
この設計で「無料枠の乱用」を現実的に抑えています。
12. 運用・監視
- /api/health でDB疎通確認
- PostHogでイベント計測
- AuditLogで重要操作の履歴を保存
13. 学びと工夫
Prismaとpgvector
Prismaはvector型に対応していないため、Raw SQLで補完。危険度は高いが、
- 埋め込み生成・検索は限定的な箇所に集中
- パラメータは必ずバインド
で安全性と可読性を確保しました。
LLMの信頼性
1回の生成に依存すると崩壊するため、
- JSONモード
- Zodで強制バリデーション
- フェーズ分割
を徹底しています。
14. 今後の改善案
- ES編集画面のUX強化(2カラム化・差分表示)
- PDF/Word出力のテンプレート化
- 推薦(Recommendation)ロジックの本実装
- 非同期ジョブ化(BullMQなど)で生成待ちのUX改善
まとめ
ES Coachは、
- RAGによる企業理解
- 構造化ES生成
- 面接音声シミュレーション
- 決済とトライアル管理
を一体化したフルスタックプロダクトです。
就活支援というドメイン特有の課題に対して、
LLMの信頼性設計・セキュリティ・運用面まで含めて実装した経験は大きな学びになりました。
もし同様のAIプロダクトを検討されている方の参考になれば嬉しいです。