@cablate/banini-tracker 2.0.10 → 2.0.12

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
@@ -4,17 +4,18 @@
4
4
 
5
5
  # banini-tracker
6
6
 
7
- 追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送。
7
+ 追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送,並自動追蹤預測準確度。
8
8
 
9
9
  - 辨識她提到的標的(個股、ETF、原物料)
10
10
  - 判斷她的操作(買入 / 被套 / 停損)
11
11
  - 反轉推導(她停損 → 可能反彈、她買入 → 可能下跌)
12
12
  - 推導連鎖效應(油價跌 → 製造業利多 → 電子股受惠)
13
+ - 自動記錄預測,追蹤 5 個交易日的實際走勢
13
14
 
14
15
  > **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。
15
16
 
16
17
  支援兩種使用模式:
17
- - **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + Telegram 推送
18
+ - **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + Telegram 推送 + 預測追蹤
18
19
  - **CLI 工具**:`npx @cablate/banini-tracker`,搭配 Claude Code 等 AI 手動執行分析
19
20
 
20
21
  ## 快速開始(常駐排程)
@@ -26,7 +27,7 @@ cp .env.example .env
26
27
 
27
28
  # 2. Docker 部署
28
29
  docker build -t banini-tracker .
29
- docker run -d --name banini --env-file .env banini-tracker
30
+ docker run -d --name banini --env-file .env -v banini-data:/data banini-tracker
30
31
 
31
32
  # 3. 或本地直接跑
32
33
  npm install && npm run start
@@ -34,18 +35,24 @@ npm install && npm run start
34
35
 
35
36
  ### 排程規則
36
37
 
37
- - **盤中**(週一~五 09:00-13:30):每 30 分鐘,FB only 抓 1 篇
38
- - **盤後**(每天 23:00):FB 3 篇
38
+ | 排程 | 時間 | 說明 |
39
+ |------|------|------|
40
+ | 早晨補漏 | 每天 08:00 | 抓前一晚 22:00 後的貼文(3 篇) |
41
+ | 盤中 | 週一~五 09:07-13:07 每 30 分 | 抓 08:30 後的貼文(1 篇) |
42
+ | 追蹤更新 | 週一~五 15:00 | 更新預測追蹤(收盤後抓 OHLC) |
43
+ | 盤後 | 每天 23:03 | 抓 13:30 後的貼文(3 篇) |
44
+
45
+ 每個排程只抓自己時間窗口內的貼文,搭配 seen.json 去重,確保無死角且不重複。
39
46
 
40
47
  ### npm scripts
41
48
 
42
49
  | 指令 | 說明 |
43
50
  |------|------|
44
- | `npm run start` | 常駐排程模式(盤中 + 盤後自動跑) |
51
+ | `npm run start` | 常駐排程模式(全部排程自動跑) |
45
52
  | `npm run dev` | 單次執行(FB 3 篇) |
46
53
  | `npm run dry` | 只抓取,不呼叫 LLM |
47
- | `npm run market` | 盤中模式(FB only, 1 篇) |
48
- | `npm run evening` | 盤後模式(各 3 篇) |
54
+ | `npm run market` | 盤中模式(FB 1 篇) |
55
+ | `npm run evening` | 盤後模式(FB 3 篇) |
49
56
 
50
57
  ### .env 設定
51
58
 
@@ -60,8 +67,37 @@ TG_CHANNEL_ID=-100...
60
67
  # 影片轉錄(選填,啟用後自動轉錄影片貼文)
61
68
  TRANSCRIBER=groq
62
69
  GROQ_API_KEY=gsk_...
70
+
71
+ # FinMind API(選填,免費可用,註冊可提高額度)
72
+ FINMIND_TOKEN=...
73
+
74
+ # 資料目錄(Docker 建議掛載 /data)
75
+ DATA_DIR=/data
63
76
  ```
64
77
 
78
+ ## 預測追蹤系統
79
+
80
+ LLM 分析出標的後,系統自動:
81
+
82
+ 1. **映射股票代碼**:台股名稱 → 代碼(2230 檔上市 + 上櫃)
83
+ 2. **記錄基準價格**:以貼文發佈時間查對應交易日收盤價
84
+ 3. **追蹤 5 個交易日**:每天 15:00 收盤後抓 OHLC,記錄漲跌幅
85
+ 4. **同股票取代**:新預測自動取代同標的舊預測(supersede 機制)
86
+
87
+ 勝敗判定在查詢時決定,支援多維度分析(不同持有天數、信心度分群、操作類型)。
88
+
89
+ ### 資料儲存
90
+
91
+ 使用 SQLite(better-sqlite3),資料表:
92
+
93
+ | 表 | 用途 |
94
+ |----|------|
95
+ | `posts` | 所有貼文原文(即時 + 歷史回測統一來源) |
96
+ | `predictions` | 預測記錄(標的、方向、基準價、狀態) |
97
+ | `price_snapshots` | 每日 OHLC 快照(5 天追蹤期) |
98
+
99
+ 資料庫位置:`$DATA_DIR/banini.db`(Docker 掛載 `/data`,本地 `~/.banini-tracker/`)
100
+
65
101
  ## CLI 工具模式
66
102
 
67
103
  不需 clone repo,任何環境直接用:
@@ -76,8 +112,11 @@ npx @cablate/banini-tracker init \
76
112
  # 抓取 Facebook 最新 3 篇
77
113
  npx @cablate/banini-tracker fetch -s fb -n 3 --mark-seen
78
114
 
115
+ # 抓取指定日期區間(回測用)
116
+ npx @cablate/banini-tracker fetch --since 2025-04-01 --until 2025-05-01 -n 100
117
+
79
118
  # 推送結果到 Telegram
80
- npx @cablate/banini-tracker push -m "分析結果..."
119
+ npx @cablate/banini-tracker push -f report.txt
81
120
  ```
82
121
 
83
122
  ### CLI 指令
@@ -97,6 +136,8 @@ npx @cablate/banini-tracker push -m "分析結果..."
97
136
  ```
98
137
  -s, --source <source> 來源:fb(預設 fb)
99
138
  -n, --limit <n> 每個來源抓幾篇(預設 3)
139
+ --since <date> 只抓此時間之後的貼文(YYYY-MM-DD / ISO 時間戳 / 相對時間如 "2 months")
140
+ --until <date> 只抓此時間之前的貼文
100
141
  --no-dedup 不去重
101
142
  --mark-seen 輸出後自動標記已讀
102
143
  ```
@@ -105,7 +146,7 @@ npx @cablate/banini-tracker push -m "分析結果..."
105
146
 
106
147
  ```
107
148
  -m, --message <text> 直接帶訊息
108
- -f, --file <path> 從檔案讀取
149
+ -f, --file <path> 從檔案讀取(推薦多行內容用這個)
109
150
  --parse-mode <mode> HTML / Markdown / none(預設 HTML)
110
151
  ```
111
152
 
@@ -117,7 +158,7 @@ npx @cablate/banini-tracker push -m "分析結果..."
117
158
 
118
159
  1. `fetch` 抓貼文 → Claude 讀 JSON
119
160
  2. Claude 分析 + WebSearch 查最新走勢
120
- 3. Claude 組報告 → `push` 推送 Telegram
161
+ 3. Claude 組報告 → `push -f` 推送 Telegram
121
162
 
122
163
  詳見 [`skill/SKILL.md`](skill/SKILL.md)。
123
164
 
@@ -125,14 +166,14 @@ npx @cablate/banini-tracker push -m "分析結果..."
125
166
 
126
167
  | 項目 | 單次費用 | 頻率 | 月估算 |
127
168
  |------|---------|------|--------|
128
- | Facebook 抓取(Apify) | ~$0.02 | 盤中 ~198 + 盤後 30 次 | ~$4.56 |
169
+ | Facebook 抓取(Apify) | ~$0.005/篇 | ~270 篇/月 | ~$1.35 |
129
170
  | LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 |
130
171
  | 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 |
172
+ | 股價查詢(FinMind) | 免費 | 每日收盤後 | $0 |
131
173
  | Telegram 推送 | 免費 | — | $0 |
132
174
 
133
- > 盤中:週一~五 09:00-13:30 30 分鐘(~9 次/日 × 22 工作日)
134
- > 盤後:每天 23:00(30 次/月)
135
- > CLI 模式搭配 Claude Code 使用則不需 LLM 費用,Claude 自己分析
175
+ > CLI 模式搭配 Claude Code 使用不需 LLM 費用,Claude 自己分析。
176
+ > 回測歷史資料加日期篩選:~$7/千篇($5 基本 + $2 date filter add-on)。
136
177
 
137
178
  ## 為什麼只用 Facebook?
138
179
 
package/dist/cli.js CHANGED
@@ -5,6 +5,8 @@ import { fetchFacebookPosts } from './facebook.js';
5
5
  import { sendTelegramMessage } from './telegram.js';
6
6
  import { filterNewPosts, markPostsSeen, listSeenIds, clearSeen } from './seen.js';
7
7
  import { readFileSync } from 'fs';
8
+ import { createTranscriber, transcribeVideoPosts, isVideoPost } from './transcribe.js';
9
+ import { getDb } from './db.js';
8
10
  const program = new Command();
9
11
  program
10
12
  .name('banini-tracker')
@@ -18,10 +20,13 @@ program
18
20
  .option('--tg-bot-token <token>', 'Telegram Bot token')
19
21
  .option('--tg-channel-id <id>', 'Telegram Channel ID')
20
22
  .option('--fb-page-url <url>', 'Facebook 粉專網址', 'https://www.facebook.com/DieWithoutBang/')
23
+ .option('--groq-api-key <key>', 'Groq API key(影片轉錄用)')
21
24
  .action((opts) => {
22
25
  const config = defaultConfig();
23
26
  if (opts.apifyToken)
24
27
  config.apifyToken = opts.apifyToken;
28
+ if (opts.groqApiKey)
29
+ config.groqApiKey = opts.groqApiKey;
25
30
  if (opts.tgBotToken || opts.tgChannelId) {
26
31
  config.telegram = {
27
32
  botToken: opts.tgBotToken ?? '',
@@ -66,6 +71,8 @@ program
66
71
  .option('--until <date>', '只抓此時間之前的貼文')
67
72
  .option('--no-dedup', '不做去重,抓到什麼就輸出什麼')
68
73
  .option('--mark-seen', '輸出後自動標記為已讀')
74
+ .option('--transcribe', '自動轉錄影片(captionText 為空時走 Groq Whisper)')
75
+ .option('--save-db', '抓取後直接存入 SQLite')
69
76
  .action(async (opts) => {
70
77
  try {
71
78
  const config = loadConfig();
@@ -80,6 +87,53 @@ program
80
87
  if (opts.dedup !== false) {
81
88
  posts = filterNewPosts(posts);
82
89
  }
90
+ // 影片轉錄:captionText 為空的影片走 Groq
91
+ if (opts.transcribe) {
92
+ const groqKey = config.groqApiKey || process.env.GROQ_API_KEY;
93
+ if (!groqKey) {
94
+ console.error('⚠ --transcribe 需要 Groq API key,請用 init --groq-api-key 設定或設定環境變數 GROQ_API_KEY');
95
+ }
96
+ else {
97
+ const needsTranscribe = posts.filter((p) => isVideoPost(p.mediaType) && !p.captionText);
98
+ if (needsTranscribe.length > 0) {
99
+ console.error(`[轉錄] ${needsTranscribe.length} 篇影片需要轉錄...`);
100
+ if (!process.env.GROQ_API_KEY)
101
+ process.env.GROQ_API_KEY = groqKey;
102
+ const transcriber = createTranscriber('groq');
103
+ const transcripts = await transcribeVideoPosts(needsTranscribe, transcriber);
104
+ for (const p of needsTranscribe) {
105
+ const result = transcripts.get(p.id);
106
+ if (result)
107
+ p.captionText = result.text;
108
+ }
109
+ }
110
+ }
111
+ }
112
+ // 存入 DB
113
+ if (opts.saveDb && posts.length > 0) {
114
+ const db = getDb();
115
+ const upsert = db.prepare(`
116
+ INSERT INTO posts (id, source, text, ocr_text, transcript_text, media_type, media_url, url, like_count, comment_count, post_timestamp, fetched_at)
117
+ VALUES (@id, @source, @text, @ocr_text, @transcript_text, @media_type, @media_url, @url, @like_count, @comment_count, @post_timestamp, @fetched_at)
118
+ ON CONFLICT(id) DO UPDATE SET
119
+ transcript_text = CASE WHEN excluded.transcript_text != '' THEN excluded.transcript_text ELSE posts.transcript_text END,
120
+ like_count = excluded.like_count,
121
+ comment_count = excluded.comment_count
122
+ `);
123
+ const now = new Date().toISOString();
124
+ db.transaction(() => {
125
+ for (const p of posts) {
126
+ upsert.run({
127
+ id: p.id, source: p.source, text: p.text,
128
+ ocr_text: p.ocrText || '', transcript_text: p.captionText || '',
129
+ media_type: p.mediaType, media_url: p.mediaUrl, url: p.url,
130
+ like_count: p.likeCount, comment_count: p.commentCount || 0,
131
+ post_timestamp: p.timestamp, fetched_at: now,
132
+ });
133
+ }
134
+ })();
135
+ console.error(`[DB] ${posts.length} 篇已存入`);
136
+ }
83
137
  // 標記已讀
84
138
  if (opts.markSeen && posts.length > 0) {
85
139
  markPostsSeen(posts.map((p) => p.id));
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export interface Config {
2
2
  apifyToken: string;
3
+ groqApiKey?: string;
3
4
  telegram?: {
4
5
  botToken: string;
5
6
  channelId: string;
package/dist/index.js CHANGED
@@ -267,42 +267,20 @@ async function runInner(opts) {
267
267
  writeFileSync(outFile, JSON.stringify({ timestamp: new Date().toISOString(), posts: newPosts, analysis }, null, 2), 'utf-8');
268
268
  console.log(`結果已存檔: ${outFile}`);
269
269
  }
270
- /**
271
- * 產生台北時間今天指定時分的 ISO 時間戳
272
- * 用於 Apify onlyPostsNewerThan 參數
273
- */
274
- function taipeiToday(hours, minutes = 0) {
275
- const now = new Date();
276
- const taipeiStr = now.toLocaleString('en-US', { timeZone: 'Asia/Taipei' });
277
- const taipeiNow = new Date(taipeiStr);
278
- taipeiNow.setHours(hours, minutes, 0, 0);
279
- // 轉回 UTC:台北 = UTC+8
280
- const utc = new Date(taipeiNow.getTime() - 8 * 60 * 60 * 1000);
281
- return utc.toISOString();
282
- }
283
- function taipeiYesterday(hours, minutes = 0) {
284
- const now = new Date();
285
- const taipeiStr = now.toLocaleString('en-US', { timeZone: 'Asia/Taipei' });
286
- const taipeiNow = new Date(taipeiStr);
287
- taipeiNow.setDate(taipeiNow.getDate() - 1);
288
- taipeiNow.setHours(hours, minutes, 0, 0);
289
- const utc = new Date(taipeiNow.getTime() - 8 * 60 * 60 * 1000);
290
- return utc.toISOString();
291
- }
292
270
  // ── 入口 ────────────────────────────────────────────────────
293
271
  if (isCronMode) {
294
- // 早晨補漏:每天 08:00,抓前一晚 22:00 之後的貼文
272
+ // 早晨補漏:每天 08:00
295
273
  cron.schedule('0 8 * * *', () => {
296
- run({ maxPosts: 3, isDryRun: false, label: '早晨', since: taipeiYesterday(22, 0) })
274
+ run({ maxPosts: 3, isDryRun: false, label: '早晨' })
297
275
  .catch((err) => console.error('[早晨] 執行失敗:', err));
298
276
  }, { timezone: 'Asia/Taipei' });
299
- // 盤中:週一到五 09:00-13:30,每 30 分鐘,抓 08:30 之後的貼文
277
+ // 盤中:週一到五 09:00-13:30,每 30 分鐘
300
278
  cron.schedule('7,37 9-12 * * 1-5', () => {
301
- run({ maxPosts: 1, isDryRun: false, label: '盤中', since: taipeiToday(8, 30) })
279
+ run({ maxPosts: 1, isDryRun: false, label: '盤中' })
302
280
  .catch((err) => console.error('[盤中] 執行失敗:', err));
303
281
  }, { timezone: 'Asia/Taipei' });
304
282
  cron.schedule('7 13 * * 1-5', () => {
305
- run({ maxPosts: 1, isDryRun: false, label: '盤中', since: taipeiToday(8, 30) })
283
+ run({ maxPosts: 1, isDryRun: false, label: '盤中' })
306
284
  .catch((err) => console.error('[盤中] 執行失敗:', err));
307
285
  }, { timezone: 'Asia/Taipei' });
308
286
  // 追蹤更新:週一到五 15:00(收盤後更新預測追蹤)
@@ -310,16 +288,16 @@ if (isCronMode) {
310
288
  updateTracking()
311
289
  .catch((err) => console.error('[追蹤更新] 執行失敗:', err));
312
290
  }, { timezone: 'Asia/Taipei' });
313
- // 盤後:每天晚上 23:03,抓 13:30 之後的貼文
291
+ // 盤後:每天晚上 23:03
314
292
  cron.schedule('3 23 * * *', () => {
315
- run({ maxPosts: 3, isDryRun: false, label: '盤後', since: taipeiToday(13, 30) })
293
+ run({ maxPosts: 3, isDryRun: false, label: '盤後' })
316
294
  .catch((err) => console.error('[盤後] 執行失敗:', err));
317
295
  }, { timezone: 'Asia/Taipei' });
318
296
  console.log('=== 巴逆逆排程已啟動 ===');
319
- console.log(' 早晨:每天 08:00(前晚 22:00 起,3 篇)');
320
- console.log(' 盤中:週一~五 09:07/09:37/10:07/.../13:07(08:30 起,1 篇)');
297
+ console.log(' 早晨:每天 08:003 篇)');
298
+ console.log(' 盤中:週一~五 09:07/09:37/10:07/.../13:07(1 篇)');
321
299
  console.log(' 追蹤更新:週一~五 15:00(預測追蹤判定)');
322
- console.log(' 盤後:每天 23:03(13:30 起,3 篇)');
300
+ console.log(' 盤後:每天 23:03(3 篇)');
323
301
  console.log(' 按 Ctrl+C 停止\n');
324
302
  }
325
303
  else {
@@ -66,7 +66,7 @@ export class GroqTranscriber {
66
66
  };
67
67
  }
68
68
  async transcribeViaDownload(videoUrl) {
69
- console.log(`[轉錄] 下載音訊: ${videoUrl.slice(0, 60)}...`);
69
+ console.error(`[轉錄] 下載音訊: ${videoUrl.slice(0, 60)}...`);
70
70
  const audioFile = await downloadAudio(videoUrl);
71
71
  try {
72
72
  const result = await this.client.audio.transcriptions.create({
@@ -115,14 +115,14 @@ export async function transcribeVideoPosts(posts, transcriber) {
115
115
  if (!isVideoPost(post.mediaType) || !post.mediaUrl)
116
116
  continue;
117
117
  try {
118
- console.log(`[轉錄][${transcriber.name}] 處理影片: ${post.id}`);
118
+ console.error(`[轉錄][${transcriber.name}] 處理影片: ${post.id}`);
119
119
  const result = await transcriber.transcribe(post.mediaUrl);
120
120
  if (result.text.trim().length > 0) {
121
121
  results.set(post.id, result);
122
- console.log(`[轉錄] ${post.id}: ${result.text.slice(0, 50)}...(${result.durationSec ?? '?'}s)`);
122
+ console.error(`[轉錄] ${post.id}: ${result.text.slice(0, 50)}...(${result.durationSec ?? '?'}s)`);
123
123
  }
124
124
  else {
125
- console.log(`[轉錄] ${post.id}: 無可辨識內容`);
125
+ console.error(`[轉錄] ${post.id}: 無可辨識內容`);
126
126
  }
127
127
  }
128
128
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cablate/banini-tracker",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "description": "巴逆逆反指標追蹤器 — 常駐排程 + CLI 雙模式",
5
5
  "type": "module",
6
6
  "bin": {