把 YouTube 連結直接餵給 Gemini:一個 API 呼叫,和文件不會告訴你的四件事
需求很單純:用戶貼一個 YouTube 連結,App 回一組重點卡片——影片在講什麼、分成哪幾個重點、每個重點在影片的第幾分鐘。
直覺的技術路線是三段式:抓字幕 → 餵給 LLM → 整理輸出。但抓字幕這一段本身就是個沼澤:不是每支影片有字幕、自動字幕品質不穩、抓取手段又容易被平台防禦盯上。
後來發現根本不用。Gemini 的 generateContent 有一個很少人注意的能力:fileData 欄位可以直接放 YouTube URL。
整個核心就是一個請求
const res = await fetch(vertexUrl(env, model, 'generateContent'), {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
system_instruction: { parts: [{ text: systemPrompt }] },
contents: [{
role: 'user',
parts: [
{ fileData: { fileUri: videoUrl, mimeType: 'video/*' } }, // ← 就是這行
{ text: userPrompt },
],
}],
generationConfig: {
temperature: 0.3,
maxOutputTokens: 4096,
responseMimeType: 'application/json',
mediaResolution: 'MEDIA_RESOLUTION_LOW',
},
}),
});fileUri 直接放 YouTube 網址,mimeType 給 video/*,模型就會自己去「看」那支影片——畫面和聲音都看,不依賴字幕。沒有下載、沒有轉檔、沒有字幕抓取,整條管線就是一個 HTTP 請求。
跑通大概花十分鐘。接下來的四件事,是從跑通到敢給用戶用之間,實際踩出來的。
第一件事:有一整類影片會 403,而且錯誤訊息很難懂
測試階段一切順利,直到我丟了一支音樂 MV 進去:Vertex 回 403,錯誤訊息裡有一句「not owned」。
實測下來規律很清楚:受版權保護(Content ID)的影片——音樂 MV 是大宗——一律 403;演講、教學、Podcast、技術分享類,全數通過。
這件事必須在產品層處理,因為「403 not owned」對用戶來說是天書。我的做法是把這個特定錯誤轉成一句人話:
if (res.status === 403 && errText.includes('not owned')) {
throw new Error('VIDEO_COPYRIGHT');
}
// 上層轉成給用戶看的訊息:
// 「此影片受版權保護,無法解析(音樂 MV 等)。
// 演講、教學類影片沒有此限制」注意判斷條件是「403 且訊息含 not owned」——單看 403 不夠,權限設錯也是 403。把兩種混為一談,你會把自己的設定問題報告成「影片有版權」。
第二件事:mediaResolution 是成本的主開關
影片輸入的 token 消耗和「模型看得多細」直接相關。generationConfig 裡的 mediaResolution 控制每一幀影格用多少 token 表示,預設解析度下,一支演講影片的 prompt token 會相當可觀。
我的用例是「聽內容」——演講、教學的資訊密度在語音軌,畫面通常是投影片或講者。把 mediaResolution 設成 MEDIA_RESOLUTION_LOW 之後,實測輸出品質沒有可感差異:重點還是那些重點,timestamp 還是準的。
但成本差距是倍數級的。如果你的用例是「看畫面」——UI 操作教學、視覺分析——那另當別論;如果是聽內容,LOW 幾乎是白撿的折扣。這個欄位很容易被忽略,因為不設它一切也「正常」,只是帳單不正常。
順帶一提成本的量測:回應的 usageMetadata 有 promptTokenCount 和 candidatesTokenCount,把兩個加起來記進 log,你才知道每支影片實際花多少。我們同時把這個功能計入用戶的每日 AI 配額——影片是目前最貴的輸入型態,不設額度等於開放稻草人攻擊自己的帳單。
第三件事:模型回的 JSON,一個欄位都不能直接信
請求裡設了 responseMimeType: application/json,模型大多數時候會回合法的 JSON。「大多數時候」在生產環境等於「會出事」。
實際遇過的變體:整段回傳不是 JSON(直接 parse 失敗)、欄位該是字串卻回了陣列、cards 裡混進 null、points 該是陣列卻回成一整段字串。每一種都能讓天真的前端直接白屏。
所以解析層是「防禦性正規化」,對每個欄位逐一收斂型別,任何缺失都有預設值:
let data;
try {
data = JSON.parse(result.text);
} catch {
data = { title: 'YouTube 影片', tldr: '', cards: [], tags: [] };
}
// 每個欄位逐一正規化:字串就是字串,陣列就是陣列,null 一律清掉
const asStr = (v) => Array.isArray(v) ? v.filter(x => x != null).join('\n')
: v == null || typeof v === 'string' ? v : String(v);
data.cards = Array.isArray(data.cards)
? data.cards.filter(c => c && typeof c === 'object').map(c => ({
heading: asStr(c.heading) || '',
points: Array.isArray(c.points) ? c.points.filter(p => p != null).map(String) : [],
timestamp: asStr(c.timestamp) || '',
}))
: [];原則:LLM 的輸出永遠當外部輸入處理。responseMimeType 提高的是機率,不是保證;schema 的最後一道防線必須在你自己的程式裡。
第四件事:timestamp 不會自己出現,要在 prompt 裡要
重點卡片要能點回影片的對應時間點,這是體驗的關鍵。模型看得懂影片的時間軸,但你不明確要求,它就不會給——或者給了但格式每次不一樣(「三分二十秒」「3:20」「約 200 秒處」)。
解法單純但必要:在輸出 schema 裡明確定義格式。
"cards": [
{"heading": "重點標題", "points": ["要點1", "要點2"],
"timestamp": "mm:ss 該重點開始的時間"}
]
卡片數 3~7 張,依影片長度;每個要點具體、有資訊量,不要空話。把格式範例直接寫進 schema 描述(mm:ss),比在指示裡另外解釋有效得多。同一個 prompt 也順便控制卡片數量的上下限——不綁範圍,短影片會被硬擠出七張水卡,長影片會被壓成三張。
整體架構的位置
最後一個決定跟 API 無關,但同樣重要:這個呼叫放在哪裡。
答案是 server 端的 gateway,不是 App 內。三個理由:API 金鑰不落地(App 內的金鑰等於公開)、配額和權限在 server 統一管(誰是付費用戶、今天用了幾次,依 JWT 判斷而不是信 client)、模型和 prompt 可以隨時換而不用發版。
App 端只看到一個乾淨的介面:POST 一個 YouTube URL,拿回一組結構化卡片,或一句看得懂的錯誤。所有這篇講的髒活,都留在 gateway 裡。
如果你要做同一件事
一、先確認你的影片類型過得了:拿目標類型的真實影片測,音樂/影視內容會 403,不要等上線才發現。
二、成本先量再上:usageMetadata 記 log,mediaResolution 依用例選,配額從第一天就設。
三、解析層當作在接一個不可信的外部 API 寫:JSON parse 有 fallback,每個欄位正規化。
四、輸出格式(含 timestamp)在 prompt 的 schema 裡寫死,不要靠模型自由發揮。
五、呼叫放 server 端,金鑰、配額、prompt 三件事都會感謝你。
這個功能所在的產品:KOFNote