@cablate/banini-tracker 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,6 +56,10 @@ LLM_API_KEY=...
56
56
  LLM_MODEL=MiniMaxAI/MiniMax-M2.5
57
57
  TG_BOT_TOKEN=...
58
58
  TG_CHANNEL_ID=-100...
59
+
60
+ # 影片轉錄(選填,啟用後自動轉錄影片貼文)
61
+ TRANSCRIBER=groq
62
+ GROQ_API_KEY=gsk_...
59
63
  ```
60
64
 
61
65
  ## CLI 工具模式
@@ -123,12 +127,30 @@ npx @cablate/banini-tracker push -m "分析結果..."
123
127
  |------|---------|------|--------|
124
128
  | Facebook 抓取(Apify) | ~$0.02 | 盤中 ~198 次 + 盤後 30 次 | ~$4.56 |
125
129
  | LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 |
130
+ | 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 |
126
131
  | Telegram 推送 | 免費 | — | $0 |
127
132
 
128
133
  > 盤中:週一~五 09:00-13:30 每 30 分鐘(~9 次/日 × 22 工作日)
129
134
  > 盤後:每天 23:00(30 次/月)
130
135
  > CLI 模式搭配 Claude Code 使用則不需 LLM 費用,Claude 自己分析
131
136
 
137
+ ## 為什麼只用 Facebook?
138
+
139
+ 早期版本同時支援 Threads 和 Facebook 爬取,後來基於兩個原因移除了 Threads:
140
+
141
+ 1. **費用差距大**:Threads 每次抓取 ~$0.15(Pay-per-event),Facebook 只要 ~$0.02(CU 計費),差 7 倍以上
142
+ 2. **FB 參考價值更高**:巴逆逆的投資相關貼文(持倉截圖、操作心得)主要發在 Facebook 粉專,Threads 多為生活日常,反指標參考價值較低
143
+
144
+ ## Star History
145
+
146
+ <a href="https://star-history.com/#cablate/banini-tracker&Date">
147
+ <picture>
148
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=cablate/banini-tracker&type=Date&theme=dark" />
149
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=cablate/banini-tracker&type=Date" />
150
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=cablate/banini-tracker&type=Date" />
151
+ </picture>
152
+ </a>
153
+
132
154
  ## 免責聲明
133
155
 
134
156
  本專案僅供娛樂參考,不構成任何投資建議。
package/dist/facebook.js CHANGED
@@ -34,7 +34,9 @@ export async function fetchFacebookPosts(pageUrl, token, maxPosts = 3) {
34
34
  shareCount: item.shares ?? 0,
35
35
  url: item.url ?? '',
36
36
  mediaType: media?.__typename?.toLowerCase() ?? 'text',
37
- mediaUrl: media?.thumbnail ?? media?.photo_image?.uri ?? '',
37
+ mediaUrl: media?.video_url ?? media?.playable_url
38
+ ?? (media?.__typename?.toLowerCase() === 'video' ? media?.url : null)
39
+ ?? media?.thumbnail ?? media?.photo_image?.uri ?? '',
38
40
  };
39
41
  });
40
42
  }
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { analyzePosts } from './analyze.js';
16
16
  import { sendTelegramMessageWithConfig, formatReport, formatFallbackReport } from './telegram.js';
17
17
  import { filterNewPosts as filterNew, markPostsSeen } from './seen.js';
18
18
  import { withRetry } from './retry.js';
19
+ import { createTranscriber, transcribeVideoPosts } from './transcribe.js';
19
20
  // ── Config ──────────────────────────────────────────────────
20
21
  const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';
21
22
  const DATA_DIR = join(process.cwd(), 'data');
@@ -32,6 +33,7 @@ function fromFacebook(p) {
32
33
  source: 'facebook',
33
34
  text: p.text,
34
35
  ocrText: p.ocrText,
36
+ transcriptText: '',
35
37
  timestamp: p.timestamp,
36
38
  likeCount: p.likeCount,
37
39
  replyCount: p.commentCount,
@@ -78,6 +80,17 @@ async function runInner(opts) {
78
80
  console.log('沒有新貼文,結束');
79
81
  return;
80
82
  }
83
+ // 2.5. 影片轉錄
84
+ const transcriberType = (process.env.TRANSCRIBER ?? 'noop');
85
+ const transcriber = createTranscriber(transcriberType);
86
+ if (transcriber.name !== 'noop') {
87
+ const transcripts = await transcribeVideoPosts(newPosts, transcriber);
88
+ for (const p of newPosts) {
89
+ const result = transcripts.get(p.id);
90
+ if (result)
91
+ p.transcriptText = result.text;
92
+ }
93
+ }
81
94
  // 按時間從新到舊排序
82
95
  newPosts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
83
96
  // 標記當天貼文
@@ -97,6 +110,8 @@ async function runInner(opts) {
97
110
  const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
98
111
  console.log(`--- [${tag}]${todayTag} ${localTime} [${p.mediaType}] ---`);
99
112
  console.log(p.text || '(無文字,可能是純圖片)');
113
+ if (p.transcriptText)
114
+ console.log(`[影片轉錄] ${p.transcriptText}`);
100
115
  if (p.mediaUrl)
101
116
  console.log(`媒體: ${p.mediaUrl}`);
102
117
  console.log(`讚: ${p.likeCount} | 回覆: ${p.replyCount} | ${p.url}\n`);
@@ -107,13 +122,15 @@ async function runInner(opts) {
107
122
  }
108
123
  // 5. AI 分析
109
124
  const textsForAnalysis = newPosts
110
- .filter((p) => p.text.trim().length > 0 || p.ocrText.trim().length > 0)
125
+ .filter((p) => p.text.trim().length > 0 || p.ocrText.trim().length > 0 || p.transcriptText.trim().length > 0)
111
126
  .map((p) => {
112
127
  const tag = 'Facebook';
113
128
  const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
114
129
  let content = `[${tag}] ${p.text}`;
115
130
  if (p.ocrText)
116
131
  content += `\n[圖片 OCR] ${p.ocrText}`;
132
+ if (p.transcriptText)
133
+ content += `\n[影片轉錄] ${p.transcriptText}`;
117
134
  return { text: content, timestamp: localTime, isToday: isToday(p.timestamp) };
118
135
  });
119
136
  if (textsForAnalysis.length === 0) {
@@ -0,0 +1,30 @@
1
+ export interface TranscribeResult {
2
+ text: string;
3
+ durationSec?: number;
4
+ }
5
+ export interface Transcriber {
6
+ readonly name: string;
7
+ transcribe(videoUrl: string): Promise<TranscribeResult>;
8
+ }
9
+ export declare class NoopTranscriber implements Transcriber {
10
+ readonly name = "noop";
11
+ transcribe(_videoUrl: string): Promise<TranscribeResult>;
12
+ }
13
+ export declare class GroqTranscriber implements Transcriber {
14
+ readonly name = "groq";
15
+ private client;
16
+ private model;
17
+ constructor(apiKey: string, model?: string);
18
+ transcribe(videoUrl: string): Promise<TranscribeResult>;
19
+ private transcribeViaUrl;
20
+ private transcribeViaDownload;
21
+ }
22
+ export type TranscriberType = 'noop' | 'groq';
23
+ export declare function createTranscriber(type?: TranscriberType): Transcriber;
24
+ export declare function isVideoPost(mediaType: string): boolean;
25
+ export interface TranscribablePost {
26
+ id: string;
27
+ mediaType: string;
28
+ mediaUrl: string;
29
+ }
30
+ export declare function transcribeVideoPosts<T extends TranscribablePost>(posts: T[], transcriber: Transcriber): Promise<Map<string, TranscribeResult>>;
@@ -0,0 +1,133 @@
1
+ // ── 影片轉錄抽象層 ──────────────────────────────────────────
2
+ // 策略模式:定義轉錄介面,具體實作由外部決定。
3
+ // 新增轉錄服務時只需實作 Transcriber 介面並在 createTranscriber() 加入。
4
+ import { execFile } from 'child_process';
5
+ import { createReadStream, unlinkSync, mkdirSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { tmpdir } from 'os';
8
+ import { promisify } from 'util';
9
+ import Groq from 'groq-sdk';
10
+ const execFileAsync = promisify(execFile);
11
+ // ── Noop(預設:不轉錄)────────────────────────────────────
12
+ export class NoopTranscriber {
13
+ name = 'noop';
14
+ async transcribe(_videoUrl) {
15
+ return { text: '' };
16
+ }
17
+ }
18
+ // ── Groq Whisper ────────────────────────────────────────────
19
+ // Facebook 影片 URL 通常是頁面連結(reel/watch),Groq 無法直接存取。
20
+ // 流程:yt-dlp 下載音訊 → 傳檔案給 Groq Whisper → 清理暫存檔。
21
+ function needsDownload(url) {
22
+ return /facebook\.com\/(reel|watch|video)/i.test(url);
23
+ }
24
+ async function downloadAudio(videoUrl) {
25
+ const tmpDir = join(tmpdir(), 'banini-tracker');
26
+ mkdirSync(tmpDir, { recursive: true });
27
+ const outTemplate = join(tmpDir, `audio-${Date.now}.%(ext)s`);
28
+ const outFile = join(tmpDir, `audio-${Date.now()}.m4a`);
29
+ await execFileAsync('yt-dlp', [
30
+ '-f', 'ba',
31
+ '--extract-audio',
32
+ '--audio-format', 'm4a',
33
+ '-o', outFile.replace('.m4a', '.%(ext)s'),
34
+ '--no-playlist',
35
+ '--quiet',
36
+ videoUrl,
37
+ ], { timeout: 60_000 });
38
+ return outFile;
39
+ }
40
+ export class GroqTranscriber {
41
+ name = 'groq';
42
+ client;
43
+ model;
44
+ constructor(apiKey, model = 'whisper-large-v3') {
45
+ this.client = new Groq({ apiKey });
46
+ this.model = model;
47
+ }
48
+ async transcribe(videoUrl) {
49
+ if (needsDownload(videoUrl)) {
50
+ return this.transcribeViaDownload(videoUrl);
51
+ }
52
+ return this.transcribeViaUrl(videoUrl);
53
+ }
54
+ async transcribeViaUrl(url) {
55
+ const result = await this.client.audio.transcriptions.create({
56
+ url,
57
+ model: this.model,
58
+ language: 'zh',
59
+ temperature: 0,
60
+ response_format: 'verbose_json',
61
+ });
62
+ const raw = result;
63
+ return {
64
+ text: result.text ?? '',
65
+ durationSec: raw.duration ? Math.round(raw.duration) : undefined,
66
+ };
67
+ }
68
+ async transcribeViaDownload(videoUrl) {
69
+ console.log(`[轉錄] 下載音訊: ${videoUrl.slice(0, 60)}...`);
70
+ const audioFile = await downloadAudio(videoUrl);
71
+ try {
72
+ const result = await this.client.audio.transcriptions.create({
73
+ file: createReadStream(audioFile),
74
+ model: this.model,
75
+ language: 'zh',
76
+ temperature: 0,
77
+ response_format: 'verbose_json',
78
+ });
79
+ const raw = result;
80
+ return {
81
+ text: result.text ?? '',
82
+ durationSec: raw.duration ? Math.round(raw.duration) : undefined,
83
+ };
84
+ }
85
+ finally {
86
+ try {
87
+ unlinkSync(audioFile);
88
+ }
89
+ catch { }
90
+ }
91
+ }
92
+ }
93
+ export function createTranscriber(type = 'noop') {
94
+ switch (type) {
95
+ case 'noop':
96
+ return new NoopTranscriber();
97
+ case 'groq': {
98
+ const apiKey = process.env.GROQ_API_KEY;
99
+ if (!apiKey)
100
+ throw new Error('GROQ_API_KEY 環境變數未設定');
101
+ return new GroqTranscriber(apiKey, process.env.GROQ_WHISPER_MODEL ?? 'whisper-large-v3');
102
+ }
103
+ default:
104
+ throw new Error(`不支援的轉錄器類型: ${type}`);
105
+ }
106
+ }
107
+ // ── 輔助:判斷貼文是否為影片 ────────────────────────────────
108
+ export function isVideoPost(mediaType) {
109
+ const videoTypes = ['video', 'native_video', 'live_video', 'reel'];
110
+ return videoTypes.includes(mediaType.toLowerCase());
111
+ }
112
+ export async function transcribeVideoPosts(posts, transcriber) {
113
+ const results = new Map();
114
+ for (const post of posts) {
115
+ if (!isVideoPost(post.mediaType) || !post.mediaUrl)
116
+ continue;
117
+ try {
118
+ console.log(`[轉錄][${transcriber.name}] 處理影片: ${post.id}`);
119
+ const result = await transcriber.transcribe(post.mediaUrl);
120
+ if (result.text.trim().length > 0) {
121
+ results.set(post.id, result);
122
+ console.log(`[轉錄] ${post.id}: ${result.text.slice(0, 50)}...(${result.durationSec ?? '?'}s)`);
123
+ }
124
+ else {
125
+ console.log(`[轉錄] ${post.id}: 無可辨識內容`);
126
+ }
127
+ }
128
+ catch (err) {
129
+ console.error(`[轉錄] ${post.id} 失敗: ${err instanceof Error ? err.message : err}`);
130
+ }
131
+ }
132
+ return results;
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cablate/banini-tracker",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "巴逆逆反指標追蹤器 — 常駐排程 + CLI 雙模式",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "dev": "tsx src/index.ts",
11
11
  "dry": "tsx src/index.ts --dry",
12
- "market": "tsx src/index.ts --fb-only --max-posts=1",
12
+ "market": "tsx src/index.ts --max-posts=1",
13
13
  "evening": "tsx src/index.ts --max-posts=3",
14
14
  "cron": "tsx src/index.ts --cron",
15
15
  "cli": "tsx src/cli.ts",
@@ -20,7 +20,6 @@
20
20
  "keywords": [
21
21
  "banini",
22
22
  "reverse-indicator",
23
- "threads",
24
23
  "facebook",
25
24
  "apify",
26
25
  "telegram",
@@ -31,6 +30,7 @@
31
30
  "dependencies": {
32
31
  "commander": "^13.0.0",
33
32
  "dotenv": "^16.4.0",
33
+ "groq-sdk": "^1.1.2",
34
34
  "node-cron": "^4.2.1",
35
35
  "openai": "^4.0.0"
36
36
  },