把 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 都沒有看到明顯差異。
LOW 同時明顯降低了這批測試的 prompt token。UI 操作教學或視覺分析需要「看畫面」,不能照搬這個設定;實際能省多少,仍要依影片和模型量測。這個欄位很容易被忽略,因為不設定時功能照樣會跑,只有帳單先看出差別。
成本也要實際量。回應的 usageMetadata 會提供 promptTokenCount 和 candidatesTokenCount,把兩者加總後寫進 log,才能知道每支影片花了多少。我們也把影片處理計入使用者的每日 AI 配額。在 KOFNote 目前支援的輸入裡,影片是成本最高的一種;不設額度,很容易讓少數大量請求直接推高帳單。
第三件事:模型回的 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 裡要求
重點卡片要能點回影片的對應時間,這是體驗的關鍵。模型看得懂影片時間軸,但沒有明確要求時,它可能完全不給 timestamp,也可能每次換一種格式,例如「三分二十秒」「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 統一判斷;模型和 prompt 也能隨時替換,不必為此發布新版 App。
App 端只需要一個乾淨的介面:POST 一個 YouTube URL,拿回一組結構化卡片,或一句看得懂的錯誤。前面提到的例外與防線,全留在 gateway 裡。
如果你要做同一件事
一、先拿目標類型的真實影片測試。音樂與影視內容可能回 403,別等上線才發現。
二、先量成本再上線。把 usageMetadata 寫進 log,依使用情境選 mediaResolution,配額則從第一天就設好。
三、把解析層當成不可信的外部 API。JSON parse 要有 fallback,每個欄位都要正規化。
四、在 prompt 的 schema 裡寫清楚輸出格式,包括 timestamp,不要讓模型自由選格式。
五、呼叫放在 server 端,集中保管金鑰、配額與 prompt。
這個功能所在的產品:KOFNote