From 9f80575525be52008951a1593e10eee7871b2cde Mon Sep 17 00:00:00 2001 From: CabLate Date: Fri, 10 Apr 2026 14:05:08 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BD=B1=E7=89=87?= =?UTF-8?q?=E8=BD=89=E9=8C=84=E6=8A=BD=E8=B1=A1=E5=B1=A4=EF=BC=88Issue=20#?= =?UTF-8?q?4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/transcribe.ts:Transcriber 介面(策略模式)+ 工廠函式 - NoopTranscriber(預設:不轉錄) - isVideoPost() 判斷影片貼文 - transcribeVideoPosts() 批次處理 - UnifiedPost 新增 transcriptText 欄位 - index.ts:抓取後自動對影片貼文觸發轉錄,分析時包含轉錄文字 - facebook.ts:mediaUrl 優先取 video_url/playable_url(影片實際 URL) 具體轉錄服務實作待定,只需新增 Transcriber 實作並註冊到工廠。 透過 TRANSCRIBER 環境變數選擇轉錄器(預設 noop)。 Closes #4 Co-Authored-By: Claude Opus 4.6 --- src/facebook.ts | 2 +- src/index.ts | 18 ++++++++++- src/transcribe.ts | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 src/transcribe.ts diff --git a/src/facebook.ts b/src/facebook.ts index 491184f..7688265 100644 --- a/src/facebook.ts +++ b/src/facebook.ts @@ -57,7 +57,7 @@ export async function fetchFacebookPosts( shareCount: item.shares ?? 0, url: item.url ?? '', mediaType: media?.__typename?.toLowerCase() ?? 'text', - mediaUrl: media?.thumbnail ?? media?.photo_image?.uri ?? '', + mediaUrl: media?.video_url ?? media?.playable_url ?? media?.thumbnail ?? media?.photo_image?.uri ?? '', }; }); } diff --git a/src/index.ts b/src/index.ts index 1b8d978..a6060d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { analyzePosts } from './analyze.js'; import { sendTelegramMessageWithConfig, formatReport, formatFallbackReport } from './telegram.js'; import { filterNewPosts as filterNew, markPostsSeen } from './seen.js'; import { withRetry } from './retry.js'; +import { createTranscriber, transcribeVideoPosts, type TranscriberType } from './transcribe.js'; // ── Config ────────────────────────────────────────────────── const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/'; @@ -41,6 +42,7 @@ interface UnifiedPost { mediaType: string; mediaUrl: string; ocrText: string; + transcriptText: string; } function fromFacebook(p: FacebookPost): UnifiedPost { @@ -49,6 +51,7 @@ function fromFacebook(p: FacebookPost): UnifiedPost { source: 'facebook', text: p.text, ocrText: p.ocrText, + transcriptText: '', timestamp: p.timestamp, likeCount: p.likeCount, replyCount: p.commentCount, @@ -112,6 +115,17 @@ async function runInner(opts: RunOptions) { return; } + // 2.5. 影片轉錄 + const transcriberType = (process.env.TRANSCRIBER ?? 'noop') as TranscriberType; + const transcriber = createTranscriber(transcriberType); + if (transcriber.name !== 'noop') { + const transcripts = await transcribeVideoPosts(newPosts, transcriber); + for (const p of newPosts) { + const result = transcripts.get(p.id); + if (result) p.transcriptText = result.text; + } + } + // 按時間從新到舊排序 newPosts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); @@ -135,6 +149,7 @@ async function runInner(opts: RunOptions) { const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }); console.log(`--- [${tag}]${todayTag} ${localTime} [${p.mediaType}] ---`); console.log(p.text || '(無文字,可能是純圖片)'); + if (p.transcriptText) console.log(`[影片轉錄] ${p.transcriptText}`); if (p.mediaUrl) console.log(`媒體: ${p.mediaUrl}`); console.log(`讚: ${p.likeCount} | 回覆: ${p.replyCount} | ${p.url}\n`); } @@ -146,12 +161,13 @@ async function runInner(opts: RunOptions) { // 5. AI 分析 const textsForAnalysis = newPosts - .filter((p) => p.text.trim().length > 0 || p.ocrText.trim().length > 0) + .filter((p) => p.text.trim().length > 0 || p.ocrText.trim().length > 0 || p.transcriptText.trim().length > 0) .map((p) => { const tag = 'Facebook'; const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }); let content = `[${tag}] ${p.text}`; if (p.ocrText) content += `\n[圖片 OCR] ${p.ocrText}`; + if (p.transcriptText) content += `\n[影片轉錄] ${p.transcriptText}`; return { text: content, timestamp: localTime, isToday: isToday(p.timestamp) }; }); diff --git a/src/transcribe.ts b/src/transcribe.ts new file mode 100644 index 0000000..4be46e0 --- /dev/null +++ b/src/transcribe.ts @@ -0,0 +1,76 @@ +// ── 影片轉錄抽象層 ────────────────────────────────────────── +// 策略模式:定義轉錄介面,具體實作由外部決定。 +// 新增轉錄服務時只需實作 Transcriber 介面並在 createTranscriber() 加入。 + +export interface TranscribeResult { + text: string; + durationSec?: number; +} + +export interface Transcriber { + readonly name: string; + transcribe(videoUrl: string): Promise; +} + +// ── Noop(預設:不轉錄)──────────────────────────────────── + +export class NoopTranscriber implements Transcriber { + readonly name = 'noop'; + async transcribe(_videoUrl: string): Promise { + return { text: '' }; + } +} + +// ── 工廠 ────────────────────────────────────────────────── + +export type TranscriberType = 'noop'; + +export function createTranscriber(type: TranscriberType = 'noop'): Transcriber { + switch (type) { + case 'noop': + return new NoopTranscriber(); + default: + throw new Error(`不支援的轉錄器類型: ${type}`); + } +} + +// ── 輔助:判斷貼文是否為影片 ──────────────────────────────── + +export function isVideoPost(mediaType: string): boolean { + const videoTypes = ['video', 'native_video', 'live_video', 'reel']; + return videoTypes.includes(mediaType.toLowerCase()); +} + +// ── 批次轉錄 ──────────────────────────────────────────────── + +export interface TranscribablePost { + id: string; + mediaType: string; + mediaUrl: string; +} + +export async function transcribeVideoPosts( + posts: T[], + transcriber: Transcriber, +): Promise> { + const results = new Map(); + + for (const post of posts) { + if (!isVideoPost(post.mediaType) || !post.mediaUrl) continue; + + try { + console.log(`[轉錄][${transcriber.name}] 處理影片: ${post.id}`); + const result = await transcriber.transcribe(post.mediaUrl); + if (result.text.trim().length > 0) { + results.set(post.id, result); + console.log(`[轉錄] ${post.id}: ${result.text.slice(0, 50)}...(${result.durationSec ?? '?'}s)`); + } else { + console.log(`[轉錄] ${post.id}: 無可辨識內容`); + } + } catch (err) { + console.error(`[轉錄] ${post.id} 失敗: ${err instanceof Error ? err.message : err}`); + } + } + + return results; +} From 233480a14f9ac20f58760dd769698b54b6c300cc Mon Sep 17 00:00:00 2001 From: CabLate Date: Fri, 10 Apr 2026 17:28:47 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=AF=A6=E4=BD=9C=20Groq=20Whisper=20?= =?UTF-8?q?=E5=BD=B1=E7=89=87=E8=BD=89=E9=8C=84=EF=BC=88Issue=20#4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GroqTranscriber:直接傳影片 URL 給 Groq Whisper API,不需下載到本地 - 使用 whisper-large-v3 模型,language=zh 優化中文辨識 - 透過 TRANSCRIBER=groq + GROQ_API_KEY 環境變數啟用 - README 補充 .env 設定與費用估算 Co-Authored-By: Claude Opus 4.6 --- README.md | 5 +++++ package-lock.json | 10 ++++++++++ package.json | 1 + src/transcribe.ts | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 426aa56..be6f173 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ LLM_API_KEY=... LLM_MODEL=MiniMaxAI/MiniMax-M2.5 TG_BOT_TOKEN=... TG_CHANNEL_ID=-100... + +# 影片轉錄(選填,啟用後自動轉錄影片貼文) +TRANSCRIBER=groq +GROQ_API_KEY=gsk_... ``` ## CLI 工具模式 @@ -123,6 +127,7 @@ npx @cablate/banini-tracker push -m "分析結果..." |------|---------|------|--------| | Facebook 抓取(Apify) | ~$0.02 | 盤中 ~198 次 + 盤後 30 次 | ~$4.56 | | LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 | +| 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 | | Telegram 推送 | 免費 | — | $0 | > 盤中:週一~五 09:00-13:30 每 30 分鐘(~9 次/日 × 22 工作日) diff --git a/package-lock.json b/package-lock.json index f2efabc..37dd3f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "commander": "^13.0.0", "dotenv": "^16.4.0", + "groq-sdk": "^1.1.2", "node-cron": "^4.2.1", "openai": "^4.0.0" }, @@ -808,6 +809,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/groq-sdk": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-1.1.2.tgz", + "integrity": "sha512-CZO0XUQQDhn43ri1+lZHxZKpb+bGutgTvFmCJtooexiitGmPqhm1hntOT3nCoaq07e+OpeokVnfUs0i/oQuUaQ==", + "license": "Apache-2.0", + "bin": { + "groq-sdk": "bin/cli" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", diff --git a/package.json b/package.json index 3f2f006..a1aafc3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dependencies": { "commander": "^13.0.0", "dotenv": "^16.4.0", + "groq-sdk": "^1.1.2", "node-cron": "^4.2.1", "openai": "^4.0.0" }, diff --git a/src/transcribe.ts b/src/transcribe.ts index 4be46e0..7008678 100644 --- a/src/transcribe.ts +++ b/src/transcribe.ts @@ -2,6 +2,8 @@ // 策略模式:定義轉錄介面,具體實作由外部決定。 // 新增轉錄服務時只需實作 Transcriber 介面並在 createTranscriber() 加入。 +import Groq from 'groq-sdk'; + export interface TranscribeResult { text: string; durationSec?: number; @@ -21,14 +23,47 @@ export class NoopTranscriber implements Transcriber { } } +// ── Groq Whisper ──────────────────────────────────────────── + +export class GroqTranscriber implements Transcriber { + readonly name = 'groq'; + private client: Groq; + private model: string; + + constructor(apiKey: string, model = 'whisper-large-v3') { + this.client = new Groq({ apiKey }); + this.model = model; + } + + async transcribe(videoUrl: string): Promise { + const result = await this.client.audio.transcriptions.create({ + url: videoUrl, + model: this.model, + language: 'zh', + temperature: 0, + response_format: 'verbose_json', + }); + const raw = result as any; + return { + text: result.text ?? '', + durationSec: raw.duration ? Math.round(raw.duration) : undefined, + }; + } +} + // ── 工廠 ────────────────────────────────────────────────── -export type TranscriberType = 'noop'; +export type TranscriberType = 'noop' | 'groq'; export function createTranscriber(type: TranscriberType = 'noop'): Transcriber { switch (type) { case 'noop': return new NoopTranscriber(); + case 'groq': { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) throw new Error('GROQ_API_KEY 環境變數未設定'); + return new GroqTranscriber(apiKey, process.env.GROQ_WHISPER_MODEL ?? 'whisper-large-v3'); + } default: throw new Error(`不支援的轉錄器類型: ${type}`); } From 05c185f6ca4bc7c40d38f1c968ca8fe39f1495f9 Mon Sep 17 00:00:00 2001 From: CabLate Date: Fri, 10 Apr 2026 17:37:38 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=BD=B1=E7=89=87?= =?UTF-8?q?=E8=BD=89=E9=8C=84=EF=BC=9A=E7=94=A8=20yt-dlp=20=E4=B8=8B?= =?UTF-8?q?=E8=BC=89=E9=9F=B3=E8=A8=8A=E5=86=8D=E9=80=81=20Groq=20Whisper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facebook 影片 URL 實際是頁面連結(reel/watch),Groq API 無法直接存取。 改為:偵測 Facebook URL → yt-dlp 下載音訊 → 傳檔案給 Groq → 清理暫存。 非 Facebook 的直連 URL 仍走 url 參數快速路徑。 同時修正 facebook.ts mediaUrl 取值:影片貼文優先取 media.url(reel 連結), 而非 thumbnail(縮圖 jpg),確保轉錄器拿到正確的來源。 已驗證:3 個影片貼文全部成功轉錄(28s/19s/49s)。 Co-Authored-By: Claude Opus 4.6 --- src/facebook.ts | 4 ++- src/transcribe.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/facebook.ts b/src/facebook.ts index 7688265..cbfa724 100644 --- a/src/facebook.ts +++ b/src/facebook.ts @@ -57,7 +57,9 @@ export async function fetchFacebookPosts( shareCount: item.shares ?? 0, url: item.url ?? '', mediaType: media?.__typename?.toLowerCase() ?? 'text', - mediaUrl: media?.video_url ?? media?.playable_url ?? media?.thumbnail ?? media?.photo_image?.uri ?? '', + mediaUrl: media?.video_url ?? media?.playable_url + ?? (media?.__typename?.toLowerCase() === 'video' ? media?.url : null) + ?? media?.thumbnail ?? media?.photo_image?.uri ?? '', }; }); } diff --git a/src/transcribe.ts b/src/transcribe.ts index 7008678..0e16b5e 100644 --- a/src/transcribe.ts +++ b/src/transcribe.ts @@ -2,8 +2,15 @@ // 策略模式:定義轉錄介面,具體實作由外部決定。 // 新增轉錄服務時只需實作 Transcriber 介面並在 createTranscriber() 加入。 +import { execFile } from 'child_process'; +import { createReadStream, unlinkSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { promisify } from 'util'; import Groq from 'groq-sdk'; +const execFileAsync = promisify(execFile); + export interface TranscribeResult { text: string; durationSec?: number; @@ -24,6 +31,31 @@ export class NoopTranscriber implements Transcriber { } // ── Groq Whisper ──────────────────────────────────────────── +// Facebook 影片 URL 通常是頁面連結(reel/watch),Groq 無法直接存取。 +// 流程:yt-dlp 下載音訊 → 傳檔案給 Groq Whisper → 清理暫存檔。 + +function needsDownload(url: string): boolean { + return /facebook\.com\/(reel|watch|video)/i.test(url); +} + +async function downloadAudio(videoUrl: string): Promise { + const tmpDir = join(tmpdir(), 'banini-tracker'); + mkdirSync(tmpDir, { recursive: true }); + const outTemplate = join(tmpDir, `audio-${Date.now}.%(ext)s`); + const outFile = join(tmpDir, `audio-${Date.now()}.m4a`); + + await execFileAsync('yt-dlp', [ + '-f', 'ba', + '--extract-audio', + '--audio-format', 'm4a', + '-o', outFile.replace('.m4a', '.%(ext)s'), + '--no-playlist', + '--quiet', + videoUrl, + ], { timeout: 60_000 }); + + return outFile; +} export class GroqTranscriber implements Transcriber { readonly name = 'groq'; @@ -36,8 +68,15 @@ export class GroqTranscriber implements Transcriber { } async transcribe(videoUrl: string): Promise { + if (needsDownload(videoUrl)) { + return this.transcribeViaDownload(videoUrl); + } + return this.transcribeViaUrl(videoUrl); + } + + private async transcribeViaUrl(url: string): Promise { const result = await this.client.audio.transcriptions.create({ - url: videoUrl, + url, model: this.model, language: 'zh', temperature: 0, @@ -49,6 +88,27 @@ export class GroqTranscriber implements Transcriber { durationSec: raw.duration ? Math.round(raw.duration) : undefined, }; } + + private async transcribeViaDownload(videoUrl: string): Promise { + console.log(`[轉錄] 下載音訊: ${videoUrl.slice(0, 60)}...`); + const audioFile = await downloadAudio(videoUrl); + try { + const result = await this.client.audio.transcriptions.create({ + file: createReadStream(audioFile), + model: this.model, + language: 'zh', + temperature: 0, + response_format: 'verbose_json', + }); + const raw = result as any; + return { + text: result.text ?? '', + durationSec: raw.duration ? Math.round(raw.duration) : undefined, + }; + } finally { + try { unlinkSync(audioFile); } catch {} + } + } } // ── 工廠 ────────────────────────────────────────────────── From e0dedb2c2576ca5e271aceff9ebbfbc34d5c1d37 Mon Sep 17 00:00:00 2001 From: CabLate Date: Fri, 10 Apr 2026 17:58:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?Dockerfile=20=E5=8A=A0=E5=85=A5=20yt-dlp=20?= =?UTF-8?q?+=20ffmpeg=EF=BC=88=E5=BD=B1=E7=89=87=E8=BD=89=E9=8C=84?= =?UTF-8?q?=E4=BE=9D=E8=B3=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index 7185875..e13f7bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,11 @@ FROM node:20-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 ffmpeg curl \ + && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ + && chmod a+rx /usr/local/bin/yt-dlp \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY package.json package-lock.json* ./