Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,25 @@ APIFY_TOKEN=your_apify_token_here
LLM_BASE_URL=https://api.deepinfra.com/v1/openai
LLM_API_KEY=your_api_key_here
LLM_MODEL=MiniMaxAI/MiniMax-M2.5

# Telegram(選填)
TG_BOT_TOKEN=your_telegram_bot_token
TG_CHANNEL_ID=your_telegram_channel_id

# Discord Bot(選填)
DISCORD_BOT_TOKEN=your_discord_bot_token
DISCORD_CHANNEL_ID=your_discord_channel_id

# LINE(選填)
LINE_CHANNEL_ACCESS_TOKEN=your_line_channel_access_token
LINE_TO=target_user_or_group_id

# 影片轉錄(選填,啟用後自動轉錄影片貼文)
TRANSCRIBER=groq
GROQ_API_KEY=gsk_...

# FinMind API(選填,免費可用,註冊可提高額度)
FINMIND_TOKEN=...

# 資料目錄(Docker 建議掛載 /data)
DATA_DIR=/data
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# banini-tracker

追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送,並自動追蹤預測準確度。
追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、多平台即時推送(Telegram / Discord / LINE),並自動追蹤預測準確度。

- 辨識她提到的標的(個股、ETF、原物料)
- 判斷她的操作(買入 / 被套 / 停損)
Expand Down Expand Up @@ -34,15 +34,15 @@
> **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。

支援兩種使用模式:
- **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + Telegram 推送 + 預測追蹤
- **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + 多平台推送(Telegram / Discord / LINE) + 預測追蹤
- **CLI 工具**:`npx @cablate/banini-tracker`,搭配 Claude Code 等 AI 手動執行分析

## 快速開始(常駐排程)

```bash
# 1. 複製設定
cp .env.example .env
# 填入 APIFY_TOKEN, LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, TG_BOT_TOKEN, TG_CHANNEL_ID
# 填入必要項目,並設定至少一個通知管道(Telegram / Discord / LINE)

# 2. Docker 部署
docker build -t banini-tracker .
Expand Down Expand Up @@ -83,6 +83,14 @@ LLM_MODEL=MiniMaxAI/MiniMax-M2.5
TG_BOT_TOKEN=...
TG_CHANNEL_ID=-100...

# Discord Bot(選填,與 Telegram 擇一或同時使用)
DISCORD_BOT_TOKEN=...
DISCORD_CHANNEL_ID=...

# LINE(選填,與其他通知管道同時使用)
LINE_CHANNEL_ACCESS_TOKEN=...
LINE_TO=target_user_or_group_id

# 影片轉錄(選填,啟用後自動轉錄影片貼文)
TRANSCRIBER=groq
GROQ_API_KEY=gsk_...
Expand Down Expand Up @@ -198,7 +206,8 @@ npx @cablate/banini-tracker push -f report.txt
| LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 |
| 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 |
| 股價查詢(FinMind) | 免費 | 每日收盤後 | $0 |
| Telegram 推送 | 免費 | — | $0 |
| 通知推送(TG / DC) | 免費 | — | $0 |
| LINE 推送 | Free plan 200 則/月 | 同上 | $0(一般用量) |

> CLI 模式搭配 Claude Code 使用不需 LLM 費用,Claude 自己分析。
> 回測歷史資料加日期篩選:~$7/千篇($5 基本 + $2 date filter add-on)。
Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Command } from 'commander';
import { loadConfig, saveConfig, defaultConfig, getConfigPath, getSeenFile } from './config.js';
import { fetchFacebookPosts } from './facebook.js';
import { sendTelegramMessage } from './telegram.js';
import { sendTelegramDirect } from './notifiers/index.js';
import { filterNewPosts, markPostsSeen, listSeenIds, clearSeen } from './seen.js';
import { readFileSync } from 'fs';
import { createTranscriber, transcribeVideoPosts, isVideoPost } from './transcribe.js';
Expand Down Expand Up @@ -212,7 +212,7 @@ program
if (!text) throw new Error('沒有訊息內容');

const parseMode = opts.parseMode === 'none' ? '' : opts.parseMode;
await sendTelegramMessage(config.telegram.botToken, config.telegram.channelId, text, parseMode as any);
await sendTelegramDirect(config.telegram.botToken, config.telegram.channelId, text, parseMode as any);
console.error('Telegram 訊息已發送');
} catch (err) {
console.error(err instanceof Error ? err.message : err);
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export function loadConfig(): Config {
if (!raw.targets?.facebookPageUrl) {
throw new Error('設定檔缺少 targets.facebookPageUrl 設定');
}
// 半配對警告
if (raw.telegram) {
const { botToken, channelId } = raw.telegram;
if ((botToken && !channelId) || (!botToken && channelId)) {
console.warn(`⚠ telegram 設定不完整:${botToken ? '缺少 channelId' : '缺少 botToken'},push 指令將無法使用`);
}
}
return raw as Config;
}

Expand Down
67 changes: 41 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { join } from 'path';
import cron from 'node-cron';
import { fetchFacebookPosts, type FacebookPost } from './facebook.js';
import { analyzePosts } from './analyze.js';
import { sendTelegramMessageWithConfig, formatReport, formatFallbackReport } from './telegram.js';
import { createNotifiers, type ReportData, type PostSummary } from './notifiers/index.js';
import { filterNewPosts as filterNew, markPostsSeen } from './seen.js';
import { withRetry } from './retry.js';
import { createTranscriber, transcribeVideoPosts, type TranscriberType } from './transcribe.js';
Expand Down Expand Up @@ -93,6 +93,16 @@ async function runInner(opts: RunOptions) {
console.log(`\n=== 巴逆逆反指標追蹤器 [${opts.label}] ${now} ===\n`);

const apifyToken = env('APIFY_TOKEN');

// 啟動預檢:提前警告設定問題,避免跑完抓取才炸
if (!opts.isDryRun && !process.env.LLM_API_KEY) {
console.warn('⚠ LLM_API_KEY 未設定,AI 分析將會失敗(可用 --dry 跳過分析)');
}
const transcriberType = (process.env.TRANSCRIBER ?? 'noop') as TranscriberType;
if (transcriberType === 'groq' && !process.env.GROQ_API_KEY) {
console.warn('⚠ TRANSCRIBER=groq 但 GROQ_API_KEY 未設定,影片轉錄將會失敗');
}

const allPosts: UnifiedPost[] = [];

// 1. 抓取 Facebook(含 retry)
Expand Down Expand Up @@ -121,7 +131,6 @@ async function runInner(opts: RunOptions) {
}

// 2.5. 影片轉錄(captionText 有值則跳過 Groq)
const transcriberType = (process.env.TRANSCRIBER ?? 'noop') as TranscriberType;
const transcriber = createTranscriber(transcriberType);
if (transcriber.name !== 'noop') {
const needsTranscribe = newPosts.filter((p) => !p.transcriptText);
Expand Down Expand Up @@ -262,32 +271,38 @@ async function runInner(opts: RunOptions) {

console.log('\n--- 僅供娛樂參考,不構成投資建議 ---\n');

// 7. Telegram 通知
const tgToken = process.env.TG_BOT_TOKEN;
const tgChannelId = process.env.TG_CHANNEL_ID;

if (tgToken && tgChannelId) {
try {
const postSummaries = newPosts.map((p) => ({
source: p.source,
timestamp: new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }),
isToday: isToday(p.timestamp),
text: p.text.slice(0, 60),
url: p.url,
}));
const msg = llmFailed
? formatFallbackReport(postSummaries)
: formatReport(analysis, { fb: fbCount }, postSummaries);
await withRetry(
() => sendTelegramMessageWithConfig({ botToken: tgToken, channelId: tgChannelId }, msg),
{ label: 'Telegram', maxRetries: 3, baseDelayMs: 3000 },
);
console.log('[Telegram] 通知已發送');
} catch (err) {
console.error(`[Telegram] 發送失敗(已重試 3 次): ${err instanceof Error ? err.message : err}`);
// 7. 多平台通知
const notifiers = createNotifiers();
if (notifiers.length > 0) {
const postSummaries: PostSummary[] = newPosts.map((p) => ({
source: p.source,
timestamp: new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }),
isToday: isToday(p.timestamp),
text: p.text.slice(0, 60),
url: p.url,
}));
const reportData: ReportData = {
analysis,
postCount: { fb: fbCount },
posts: postSummaries,
isFallback: llmFailed,
};

const results = await Promise.allSettled(
notifiers.map((n) =>
withRetry(() => n.send(reportData), { label: n.name, maxRetries: 3, baseDelayMs: 3000 }),
),
);
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (r.status === 'fulfilled') {
console.log(`[${notifiers[i].name}] 通知已發送`);
} else {
console.error(`[${notifiers[i].name}] 發送失敗(已重試 3 次): ${r.reason instanceof Error ? r.reason.message : r.reason}`);
}
}
} else {
console.log('[Telegram] 未設定 TG_BOT_TOKEN / TG_CHANNEL_ID,跳過通知');
console.log('[通知] 未設定任何通知管道,跳過');
}

// 8. 預測追蹤記錄
Expand Down
103 changes: 103 additions & 0 deletions src/notifiers/discord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { splitMessage } from './format.js';
import type { Notifier, ReportData } from './types.js';

const API_BASE = 'https://discord.com/api/v10';
const MAX_LEN = 2000;

export interface DiscordConfig {
botToken: string;
channelId: string;
}

async function sendMessage(config: DiscordConfig, content: string): Promise<void> {
const url = `${API_BASE}/channels/${config.channelId}/messages`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bot ${config.botToken}`,
},
body: JSON.stringify({ content }),
});

if (!res.ok) {
const respBody = await res.text().catch(() => '');
throw new Error(`Discord 發送失敗: ${res.status} ${respBody.slice(0, 200)}`);
}
}

function formatReport(report: ReportData): string {
const { analysis, postCount, posts } = report;
const lines: string[] = [];

if (report.isFallback) {
lines.push('**巴逆逆貼文速報**(LLM 分析失敗)');
lines.push('');
for (const p of posts) {
const todayTag = p.isToday ? ' [今天]' : '';
const preview = p.text.replace(/\n/g, ' ').slice(0, 80);
const link = p.url ? ` [原文](${p.url})` : '';
lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`);
}
lines.push('\n*LLM 服務暫時無法使用,僅列出原始貼文*');
return lines.join('\n');
}

lines.push('**巴逆逆反指標速報**');
lines.push(`來源:FB ${postCount.fb} 篇`);
lines.push('');

lines.push('**她的動態**');
for (const p of posts) {
const todayTag = p.isToday ? ' [今天]' : '';
const preview = p.text.replace(/\n/g, ' ').slice(0, 50);
const link = p.url ? ` [原文](${p.url})` : '';
lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}${link}`);
}

lines.push('');
lines.push(analysis.summary);

if (analysis.hasInvestmentContent) {
if (analysis.mentionedTargets?.length) {
lines.push('');
lines.push('**提及標的**');
for (const t of analysis.mentionedTargets) {
const arrow = t.reverseView.includes('漲') || t.reverseView.includes('彈') ? '↑'
: t.reverseView.includes('跌') ? '↓' : '→';
lines.push(`${arrow} **${t.name}**(${t.type})`);
lines.push(` 她:${t.herAction} → 反指標:${t.reverseView} [${t.confidence}]`);
if (t.reasoning) lines.push(` ${t.reasoning}`);
}
}
if (analysis.chainAnalysis) {
lines.push('');
lines.push(`**連鎖推導**\n${analysis.chainAnalysis}`);
}
if (analysis.actionableSuggestion) {
lines.push('');
lines.push(`**建議方向**\n${analysis.actionableSuggestion}`);
}
if (analysis.moodScore) {
lines.push(`\n冥燈指數:${analysis.moodScore}/10`);
}
} else {
lines.push('\n(本批貼文與投資無關)');
}

lines.push('\n*僅供娛樂參考,不構成投資建議*');
return lines.join('\n');
}

export function createDiscordNotifier(config: DiscordConfig): Notifier {
return {
name: 'Discord',
async send(report: ReportData): Promise<void> {
const text = formatReport(report);
const chunks = splitMessage(text, MAX_LEN);
for (const chunk of chunks) {
await sendMessage(config, chunk);
}
},
};
}
37 changes: 37 additions & 0 deletions src/notifiers/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/**
* 將長訊息分段,確保不超過平台字元上限。
* 優先在換行處切割,單行超長時強制切。
*/
export function splitMessage(text: string, maxLen: number): string[] {
if (text.length <= maxLen) return [text];

const chunks: string[] = [];
let remaining = text;

while (remaining.length > 0) {
if (remaining.length <= maxLen) {
chunks.push(remaining);
break;
}

// 找最後一個換行
let splitIdx = remaining.lastIndexOf('\n', maxLen);
if (splitIdx <= 0) {
// 沒有換行,找最後一個空格
splitIdx = remaining.lastIndexOf(' ', maxLen);
}
if (splitIdx <= 0) {
// 都沒有,強制切
splitIdx = maxLen;
}

chunks.push(remaining.slice(0, splitIdx));
remaining = remaining.slice(splitIdx).replace(/^\n/, '');
}

return chunks;
}
Loading
Loading