メインコンテンツへスキップ
← 記事一覧に戻る
·開発·15 min read

Gemini 2.5 Flash で YouTube動画からレシピを自動抽出する方法

recipe-aiGeminiYouTubeAI個人開発

YouTube の料理動画を見ながら「この材料なんだっけ」とスクロールし直す経験は誰にでもある。Google Gemini 2.5 Flash のマルチモーダル API を使えば、YouTube の URL を渡すだけで動画の中身を理解し、レシピ(材料・手順・コツ)を構造化 JSON として抽出できる。字幕がなくても、映像と音声から直接読み取る。

この記事では、筆者が個人開発した recipe-ai の実装をベースに、Gemini API でのレシピ抽出を再現可能な形で解説する。

この記事でわかること:

  • Gemini が YouTube URL をそのまま受け取れる仕組みと、従来手法との違い
  • @google/genai パッケージを使った実装の全体像
  • Structured Output と Freeform の速度・信頼性の比較データ
  • YouTube URL パース、型定義、エラーハンドリングの実コード
  • Vercel Hobby の 60 秒制限にハマった実体験と対処法

なぜGeminiなのか -- YouTube URL 直渡しという圧倒的な利点

従来、YouTube 動画からテキスト情報を得るには以下のような手順が必要だった。

  1. YouTube Data API や yt-dlp で字幕データを取得
  2. 字幕テキストを整形して LLM に投げる
  3. LLM がテキストからレシピを抽出

この方法には致命的な弱点がある。字幕がない動画では何もできない。料理動画は個人チャンネルが多く、字幕が付いていないケースが珍しくない。

Gemini のマルチモーダル API は根本的に違う。YouTube の URL をそのまま fileData として渡すと、映像と音声の両方を理解してレシピを抽出する。字幕の有無に依存しない。

// Gemini に YouTube URL を直接渡す
const response = await ai.models.generateContent({
  model: "gemini-2.5-flash",
  contents: [
    {
      role: "user",
      parts: [
        { fileData: { fileUri: youtubeUrl, mimeType: "video/*" } },
        { text: "この YouTube 料理動画を視聴し、登場する全てのレシピを JSON で抽出してください。" },
      ],
    },
  ],
});

fileData.fileUri に YouTube URL を渡し、mimeType"video/*" に設定する。これだけで Gemini が動画を「視聴」してくれる。字幕取得ライブラリも、音声文字起こし API も不要。

実装の全体像

recipe-ai の抽出フローは以下の 4 ステップで構成される。

  1. URL 受付: API Route でリクエストを受け取り、Zod でバリデーション
  2. URL パース: YouTube URL から videoId を抽出し、正規化
  3. Gemini 呼び出し: 正規化した URL を Gemini API に渡してレシピ JSON を取得
  4. レスポンス整形: 抽出結果をバリデーションし、クライアントに返却

主要ファイルの構成はこうなっている。

ファイル役割
app/api/extract/route.tsAPI Route(エントリーポイント)
lib/youtube.tsYouTube URL パース
lib/recipe.tsGemini API 呼び出し・レスポンス処理
types/recipe.tsRecipe 型定義、エラーコード

YouTube URLのパース

YouTube の URL は形式が複数ある。youtube.com/watch?v=youtu.be/shorts/embed/ の 4 パターンをすべてカバーする必要がある。

export function extractVideoId(url: string): string | null {
  try {
    const u = new URL(url);
    if (u.hostname === "youtu.be") {
      const id = u.pathname.slice(1);
      return /^[\w-]{11}$/.test(id) ? id : null;
    }
    if (
      u.hostname.endsWith("youtube.com") ||
      u.hostname.endsWith("youtube-nocookie.com")
    ) {
      const v = u.searchParams.get("v");
      if (v && /^[\w-]{11}$/.test(v)) return v;
      const m = u.pathname.match(/^\/(shorts|embed)\/([\w-]{11})/);
      if (m) return m[2];
    }
    return null;
  } catch {
    return null;
  }
}

ポイントは videoId が常に 11 文字の英数字+ハイフン+アンダースコアであること。/^[\w-]{11}$/ で厳密にチェックしている。youtube-nocookie.com(埋め込みプレーヤー)も忘れずに対応する。

抽出した videoId から正規 URL を組み立て、Gemini に渡す。

const canonicalUrl = `https://www.youtube.com/watch?v=${videoId}`;

Gemini APIでレシピ抽出

システムプロンプトの設計

プロンプト設計で最も重要なのは「推測で情報を追加させない」ことだ。料理動画は口頭で分量を省略するケースがあるが、LLM が勝手に「大さじ1」と補完すると実用上困る。

const SYSTEM_PROMPT = `あなたは日本語の料理動画からレシピを構造化抽出する専門家です。
入力された YouTube 動画(映像+音声)から、レシピ情報を JSON で抽出してください。

出力:
- is_recipe: 料理動画かどうか(boolean)
- recipes: レシピの配列。1動画に複数の料理がある場合は順番通りすべて抽出する

各レシピの必須項目:
- title: 料理名(簡潔に、最大40文字)
- ingredients: 材料の配列 [{ name: "材料名", amount: "分量(任意)" }]
- steps: 手順の配列(番号は付けない、自然な日本語で)

任意:
- servings: 何人分か
- time: 調理時間
- difficulty: "easy" | "medium" | "hard"
- tips: コツ・注意点の配列(最大5項目)

ルール:
- 料理動画でない(雑談・レビュー等)場合は is_recipe: false, recipes: []
- 1動画に複数レシピが登場する場合は全て抽出する(例: 前菜・主菜・デザート)
- 推測で材料や手順を追加しない。動画で明示されているものだけを抽出
- 聞き取り・文字認識の明らかなミスは料理文脈で自然に補完してよい`;

is_recipe フラグを設けることで、料理と無関係な動画を渡された場合にも空配列で正常終了させられる。「推測で追加しない」と「明らかなミスは補完してよい」のバランスが重要だ。

Structured Output vs Freeform の使い分け

Gemini API には 2 つの JSON 出力モードがある。

Structured OutputresponseJsonSchema でスキーマを定義し、API レベルで JSON 構造を保証する方式。

const response = await ai.models.generateContent({
  model,
  contents: [/* ... */],
  config: {
    systemInstruction: SYSTEM_PROMPT,
    temperature: 0.2,
    responseMimeType: "application/json",
    responseJsonSchema: RESPONSE_SCHEMA,
  },
});

Freeform はプロンプトの指示で JSON を出力させ、アプリ側でコードフェンスを除去する方式。

const FREEFORM_JSON_INSTRUCTION = `
出力フォーマット(これ以外の前置き・後置き・コードフェンス禁止、生JSONのみ):
{"is_recipe": boolean, "recipes": [{"title": string, ...}]}`;

Freeform モードでは、Gemini がコードフェンスで JSON を囲んでくることがあるため、手動で除去する。

const cleaned = text
  .replace(/^```json\s*/i, "")
  .replace(/^```\s*/i, "")
  .replace(/\s*```\s*$/i, "")
  .trim();

実測での比較結果はこうなった。

項目Structured OutputFreeform
平均レスポンス時間15-25 秒8-15 秒
JSON パース成功率100%約 95%(稀にフェンス除去失敗)
スキーマ違反0%約 3%(任意フィールドの型が異なる場合あり)
用途信頼性重視速度重視

recipe-ai ではデフォルトを Freeform にしている。パース失敗時のリトライを入れれば実用上の信頼性は十分で、ユーザー体験の面では速度の差が大きい。

レスポンススキーマの定義

Structured Output モードで使うスキーマは @google/genaiType ヘルパーで定義する。

import { Type } from "@google/genai";

const RECIPE_ITEM_SCHEMA = {
  type: Type.OBJECT,
  properties: {
    title: { type: Type.STRING },
    servings: { type: Type.STRING },
    time: { type: Type.STRING },
    difficulty: { type: Type.STRING, enum: ["easy", "medium", "hard"] },
    ingredients: {
      type: Type.ARRAY,
      items: {
        type: Type.OBJECT,
        properties: {
          name: { type: Type.STRING },
          amount: { type: Type.STRING },
        },
        required: ["name"],
        propertyOrdering: ["name", "amount"],
      },
    },
    steps: { type: Type.ARRAY, items: { type: Type.STRING } },
    tips: { type: Type.ARRAY, items: { type: Type.STRING } },
  },
  required: ["title", "ingredients", "steps"],
  propertyOrdering: [
    "title", "servings", "time", "difficulty",
    "ingredients", "steps", "tips",
  ],
};

const RESPONSE_SCHEMA = {
  type: Type.OBJECT,
  properties: {
    is_recipe: { type: Type.BOOLEAN },
    recipes: { type: Type.ARRAY, items: RECIPE_ITEM_SCHEMA },
  },
  required: ["is_recipe", "recipes"],
  propertyOrdering: ["is_recipe", "recipes"],
};

propertyOrdering を指定するのがポイントだ。これがないと Gemini がキーの順序を保証しないため、デバッグ時に見にくくなる。

レスポンスの型定義

TypeScript 側の型定義はシンプルに保つ。

export type Ingredient = {
  name: string;
  amount?: string;
};

export type Difficulty = "easy" | "medium" | "hard";

export type Recipe = {
  title: string;
  servings?: string;
  time?: string;
  difficulty?: Difficulty;
  ingredients: Ingredient[];
  steps: string[];
  tips?: string[];
  sourceUrl: string;
  sourceVideoTitle?: string;
};

export type ExtractMeta = {
  model: string;
  structuredOutput: boolean;
  elapsedMs: number;
};

export type ExtractApiResponse =
  | { ok: true; recipes: Recipe[]; meta: ExtractMeta }
  | { ok: false; error: ExtractErrorCode; message: string };

ExtractApiResponse は判別共用体(Discriminated Union)にしている。ok: true のときだけ recipesmeta が存在し、ok: false のときは errormessage が存在する。クライアント側で型安全にハンドリングできる。

API Route では Zod を使ってリクエストボディをバリデーションしている。

const bodySchema = z.object({
  url: z.string().url("URL の形式が正しくありません"),
  model: z.enum(MODEL_OPTIONS).optional(),
  structuredOutput: z.boolean().optional(),
});

エラーハンドリング

recipe-ai では 6 種類のエラーコードを定義している。

export type ExtractErrorCode =
  | "INVALID_URL"
  | "VIDEO_NOT_FOUND"
  | "NO_TRANSCRIPT"
  | "TOO_LONG"
  | "NOT_RECIPE"
  | "API_ERROR";
エラーコード意味対処
INVALID_URLYouTube URL として認識できないURL 形式を確認
VIDEO_NOT_FOUND動画が非公開・削除済み別の動画を試す
NO_TRANSCRIPT字幕が取得できない(Gemini 移行前の名残)Gemini では発生しにくい
TOO_LONG動画が長すぎる30 分以内の動画を使う
NOT_RECIPE料理動画ではない料理動画の URL を使う
API_ERRORGemini API のエラー(レートリミット、タイムアウト等)1 分待って再試行

API Route では Gemini からのエラーメッセージをパターンマッチで分類している。

try {
  result = await extractRecipes(canonicalUrl, {
    model: body.model,
    structuredOutput: body.structuredOutput,
  });
} catch (e) {
  const msg = e instanceof Error ? e.message : "";
  if (/not found|unavailable|private/i.test(msg)) {
    return err("VIDEO_NOT_FOUND", "動画にアクセスできません(非公開・削除の可能性)");
  }
  if (/RESOURCE_EXHAUSTED|429|quota/i.test(msg)) {
    return err("API_ERROR", "利用制限に達しました。1分ほど待ってから再試行してください", 429);
  }
  if (/timeout|aborted|AbortError/i.test(msg)) {
    return err("API_ERROR", "動画が長すぎるか、処理に時間がかかりすぎました", 504);
  }
  return err("API_ERROR", "処理に失敗しました。しばらく後に再試行してください", 502);
}

Gemini API のエラーは構造化されていないため、メッセージ文字列の正規表現マッチに頼らざるを得ない。これは実運用でエッジケースを踏みながら追加していったパターンだ。

実運用で気づいたこと

Vercel Hobby の 60 秒制限

Vercel Hobby プランのサーバーレス関数は最大 60 秒でタイムアウトする。maxDuration = 60 を設定してもこれが上限だ。

export const maxDuration = 60;

10 分以上の料理動画を Gemini に渡すと、動画の読み込みだけで数十秒かかる。Structured Output モードだと 20 秒近くかかるため、動画の読み込みと合わせて 60 秒を超えるケースが頻発した。

対処として Freeform モードをデフォルトにし、さらに長い動画への対応が必要な場合は Cloudflare Workers への移行を検討している。

Gemini 2.5 Flash vs 2.0 Flash の精度差

recipe-ai では 4 つのモデルを選択可能にしている。

export const MODEL_OPTIONS = [
  "gemini-2.5-flash",
  "gemini-2.5-flash-lite",
  "gemini-2.0-flash",
  "gemini-2.0-flash-lite",
] as const;

体感では Gemini 2.5 Flash が最も精度が高い。特に「1 動画に複数レシピが含まれるケース」での抽出漏れが少ない。2.0 Flash-Lite は速いが、材料の分量を省略しがちで、tips の品質も落ちる。

コスト面では Gemini 2.5 Flash でも無料枠で十分な量を処理できるため、精度を優先して 2.5 Flash をデフォルトにしている。

temperature は 0.2

レシピ抽出はクリエイティブな生成ではなく情報の正確な抽出が目的なので、temperature: 0.2 に設定している。0 にしないのは、まれに 0 だと応答が途切れるケースがあったため。

まとめ

Gemini 2.5 Flash の fileData に YouTube URL を渡すだけで、字幕なし動画からもレシピを構造化抽出できる。従来の字幕取得パイプラインが不要になるのは大きい。Freeform モードならレスポンスも 10 秒前後で返ってくるため、ユーザー体験としても実用的だ。

recipe-ai を試す(無料)

この記事で作っている YouTube 料理レシピ抽出アプリ本体。動画 URL を貼るだけで AI がレシピに変換。月 5 本まで無料。

関連記事

Claude Haiku → Gemini Flash に乗り換えた理由

AIモデル選定の判断基準を実体験から解説

150秒を1.9秒超えて全滅した話

無料インフラ3段階移行と月¥0運用の全記録

Claude Code で 0→MVP を1日で作る全記録

recipe-ai Build in Public の全工程を公開

Claude Crew Lab Free — 毎月の実験記録をメールで

Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。

Lab Free 登録(月1回・無料)

この記事が役に立ったらシェア