From 788f04366a63b4b94c1cfa34b567884062162b7a Mon Sep 17 00:00:00 2001 From: CabLate Date: Sat, 11 Apr 2026 22:50:01 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=A4=9A=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=9E=B6=E6=A7=8B=EF=BC=88Discord=20Bot=20+?= =?UTF-8?q?=20LINE=20+=20Telegram=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 notifiers/ 抽象層: - types.ts: Notifier 介面 + 共用型別(ReportData, PostSummary) - format.ts: splitMessage + escapeHtml 共用工具 - telegram.ts: 從舊 telegram.ts 重構,實作 Notifier 介面 - discord.ts: Discord Bot REST API(無需 discord.js) - line.ts: LINE Push Message API - index.ts: createNotifiers() dispatcher,讀 env 自動偵測啟用的 channel 重構 index.ts step 7 通知段落,改用 Promise.allSettled 並行發送。 CLI push 指令改用 sendTelegramDirect(從 notifiers re-export)。 零新增 npm 依賴。 Co-Authored-By: Claude Opus 4.6 --- .env.example | 20 ++++++ src/cli.ts | 4 +- src/index.ts | 56 ++++++++------- src/notifiers/discord.ts | 103 ++++++++++++++++++++++++++++ src/notifiers/format.ts | 37 ++++++++++ src/notifiers/index.ts | 61 +++++++++++++++++ src/notifiers/line.ts | 105 ++++++++++++++++++++++++++++ src/{ => notifiers}/telegram.ts | 117 +++++++++++++------------------- src/notifiers/types.ts | 37 ++++++++++ 9 files changed, 442 insertions(+), 98 deletions(-) create mode 100644 src/notifiers/discord.ts create mode 100644 src/notifiers/format.ts create mode 100644 src/notifiers/index.ts create mode 100644 src/notifiers/line.ts rename src/{ => notifiers}/telegram.ts (51%) create mode 100644 src/notifiers/types.ts diff --git a/.env.example b/.env.example index 3f2c045..390df92 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/cli.ts b/src/cli.ts index 99f1747..dd79468 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -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); diff --git a/src/index.ts b/src/index.ts index 785f8e6..9d252a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -262,32 +262,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. 預測追蹤記錄 diff --git a/src/notifiers/discord.ts b/src/notifiers/discord.ts new file mode 100644 index 0000000..746be6c --- /dev/null +++ b/src/notifiers/discord.ts @@ -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 { + 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 { + const text = formatReport(report); + const chunks = splitMessage(text, MAX_LEN); + for (const chunk of chunks) { + await sendMessage(config, chunk); + } + }, + }; +} diff --git a/src/notifiers/format.ts b/src/notifiers/format.ts new file mode 100644 index 0000000..102b018 --- /dev/null +++ b/src/notifiers/format.ts @@ -0,0 +1,37 @@ +export function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * 將長訊息分段,確保不超過平台字元上限。 + * 優先在換行處切割,單行超長時強制切。 + */ +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; +} diff --git a/src/notifiers/index.ts b/src/notifiers/index.ts new file mode 100644 index 0000000..1d725dd --- /dev/null +++ b/src/notifiers/index.ts @@ -0,0 +1,61 @@ +import { createTelegramNotifier } from './telegram.js'; +import { createDiscordNotifier } from './discord.js'; +import { createLineNotifier } from './line.js'; +import type { Notifier } from './types.js'; + +export type { Notifier, ReportData, PostSummary, AnalysisResult } from './types.js'; +export { sendTelegramDirect } from './telegram.js'; + +/** + * 讀取環境變數,建立所有已設定的 notifier。 + * 常駐模式用:自動偵測哪些 channel 有設定。 + */ +export function createNotifiers(): Notifier[] { + const notifiers: Notifier[] = []; + + // Telegram + const tgToken = process.env.TG_BOT_TOKEN; + const tgChannelId = process.env.TG_CHANNEL_ID; + if (tgToken && tgChannelId) { + notifiers.push(createTelegramNotifier({ botToken: tgToken, channelId: tgChannelId })); + } + + // Discord + const dcToken = process.env.DISCORD_BOT_TOKEN; + const dcChannelId = process.env.DISCORD_CHANNEL_ID; + if (dcToken && dcChannelId) { + notifiers.push(createDiscordNotifier({ botToken: dcToken, channelId: dcChannelId })); + } + + // LINE + const lineToken = process.env.LINE_CHANNEL_ACCESS_TOKEN; + const lineTo = process.env.LINE_TO; + if (lineToken && lineTo) { + notifiers.push(createLineNotifier({ channelAccessToken: lineToken, to: lineTo })); + } + + return notifiers; +} + +/** + * 從 CLI config 建立 notifier(只支援已設定的 channel)。 + */ +export function createNotifiersFromConfig(config: { + telegram?: { botToken: string; channelId: string }; + discord?: { botToken: string; channelId: string }; + line?: { channelAccessToken: string; to: string }; +}): Notifier[] { + const notifiers: Notifier[] = []; + + if (config.telegram?.botToken && config.telegram?.channelId) { + notifiers.push(createTelegramNotifier(config.telegram)); + } + if (config.discord?.botToken && config.discord?.channelId) { + notifiers.push(createDiscordNotifier(config.discord)); + } + if (config.line?.channelAccessToken && config.line?.to) { + notifiers.push(createLineNotifier(config.line)); + } + + return notifiers; +} diff --git a/src/notifiers/line.ts b/src/notifiers/line.ts new file mode 100644 index 0000000..347042e --- /dev/null +++ b/src/notifiers/line.ts @@ -0,0 +1,105 @@ +import { splitMessage } from './format.js'; +import type { Notifier, ReportData } from './types.js'; + +const API_BASE = 'https://api.line.me/v2/bot/message/push'; +const MAX_LEN = 5000; + +export interface LineConfig { + channelAccessToken: string; + to: string; // userId or groupId +} + +async function sendMessage(config: LineConfig, text: string): Promise { + const res = await fetch(API_BASE, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.channelAccessToken}`, + }, + body: JSON.stringify({ + to: config.to, + messages: [{ type: 'text', text }], + }), + }); + + if (!res.ok) { + const respBody = await res.text().catch(() => ''); + throw new Error(`LINE 發送失敗: ${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); + lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}`); + if (p.url) lines.push(` ${p.url}`); + } + lines.push('\nLLM 服務暫時無法使用,僅列出原始貼文'); + 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); + lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}`); + if (p.url) lines.push(` ${p.url}`); + } + + 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 createLineNotifier(config: LineConfig): Notifier { + return { + name: 'LINE', + async send(report: ReportData): Promise { + const text = formatReport(report); + const chunks = splitMessage(text, MAX_LEN); + for (const chunk of chunks) { + await sendMessage(config, chunk); + } + }, + }; +} diff --git a/src/telegram.ts b/src/notifiers/telegram.ts similarity index 51% rename from src/telegram.ts rename to src/notifiers/telegram.ts index 1aab124..86911b4 100644 --- a/src/telegram.ts +++ b/src/notifiers/telegram.ts @@ -1,16 +1,18 @@ +import { escapeHtml, splitMessage } from './format.js'; +import type { Notifier, ReportData } from './types.js'; + const API_BASE = 'https://api.telegram.org/bot'; +const MAX_LEN = 4096; -// ── CLI 用(直接傳參數)──────────────────────────────── -export async function sendTelegramMessage( - botToken: string, - channelId: string, - text: string, - parseMode: 'HTML' | 'Markdown' | '' = 'HTML', -): Promise { - const url = `${API_BASE}${botToken}/sendMessage`; +export interface TelegramConfig { + botToken: string; + channelId: string; +} +async function sendMessage(config: TelegramConfig, text: string, parseMode: 'HTML' | 'Markdown' | '' = 'HTML'): Promise { + const url = `${API_BASE}${config.botToken}/sendMessage`; const body: Record = { - chat_id: channelId, + chat_id: config.channelId, text, disable_web_page_preview: true, }; @@ -28,52 +30,23 @@ export async function sendTelegramMessage( } } -// ── 常駐模式用(物件參數 + formatReport)──────────────── - -export interface TelegramConfig { - botToken: string; - channelId: string; -} - -export async function sendTelegramMessageWithConfig( - config: TelegramConfig, - text: string, -): Promise { - return sendTelegramMessage(config.botToken, config.channelId, text); -} - -function escapeHtml(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>'); -} +function formatReport(report: ReportData): string { + const { analysis, postCount, posts } = report; + const lines: string[] = []; -interface PostSummary { - source: 'facebook'; - timestamp: string; - isToday: boolean; - text: string; - url: string; -} + if (report.isFallback) { + lines.push('巴逆逆貼文速報(LLM 分析失敗)'); + lines.push(''); + for (const p of posts) { + const todayTag = p.isToday ? ' [今天]' : ''; + const preview = escapeHtml(p.text.replace(/\n/g, ' ').slice(0, 80)); + const link = p.url ? ` 原文` : ''; + lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`); + } + lines.push('\nLLM 服務暫時無法使用,僅列出原始貼文'); + return lines.join('\n'); + } -export function formatReport( - analysis: { - summary: string; - hasInvestmentContent: boolean; - mentionedTargets?: { - name: string; - type: string; - herAction: string; - reverseView: string; - confidence: string; - reasoning: string; - }[]; - chainAnalysis?: string; - actionableSuggestion?: string; - moodScore?: number; - }, - postCount: { fb: number }, - posts: PostSummary[], -): string { - const lines: string[] = []; lines.push('巴逆逆反指標速報'); lines.push(`來源:FB ${postCount.fb} 篇`); lines.push(''); @@ -94,12 +67,8 @@ export function formatReport( lines.push(''); lines.push('提及標的'); for (const t of analysis.mentionedTargets) { - const arrow = - t.reverseView.includes('漲') || t.reverseView.includes('彈') - ? '↑' - : t.reverseView.includes('跌') - ? '↓' - : '→'; + const arrow = t.reverseView.includes('漲') || t.reverseView.includes('彈') ? '↑' + : t.reverseView.includes('跌') ? '↓' : '→'; lines.push(`${arrow} ${escapeHtml(t.name)}(${escapeHtml(t.type)})`); lines.push(` 她:${escapeHtml(t.herAction)} → 反指標:${escapeHtml(t.reverseView)} [${escapeHtml(t.confidence)}]`); if (t.reasoning) lines.push(` ${escapeHtml(t.reasoning)}`); @@ -124,16 +93,22 @@ export function formatReport( return lines.join('\n'); } -export function formatFallbackReport(posts: PostSummary[]): string { - const lines: string[] = []; - lines.push('巴逆逆貼文速報(LLM 分析失敗)'); - lines.push(''); - for (const p of posts) { - const todayTag = p.isToday ? ' [今天]' : ''; - const preview = escapeHtml(p.text.replace(/\n/g, ' ').slice(0, 80)); - const link = p.url ? ` 原文` : ''; - lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`); - } - lines.push('\nLLM 服務暫時無法使用,僅列出原始貼文'); - return lines.join('\n'); +export function createTelegramNotifier(config: TelegramConfig): Notifier { + return { + name: 'Telegram', + async send(report: ReportData): Promise { + const text = formatReport(report); + const chunks = splitMessage(text, MAX_LEN); + for (const chunk of chunks) { + await sendMessage(config, chunk); + } + }, + }; +} + +// CLI 用(直接傳參數發送純文字) +export async function sendTelegramDirect( + botToken: string, channelId: string, text: string, parseMode: 'HTML' | 'Markdown' | '' = 'HTML', +): Promise { + return sendMessage({ botToken, channelId }, text, parseMode); } diff --git a/src/notifiers/types.ts b/src/notifiers/types.ts new file mode 100644 index 0000000..ba16453 --- /dev/null +++ b/src/notifiers/types.ts @@ -0,0 +1,37 @@ +export interface PostSummary { + source: 'facebook'; + timestamp: string; + isToday: boolean; + text: string; + url: string; +} + +export interface MentionedTarget { + name: string; + type: string; + herAction: string; + reverseView: string; + confidence: string; + reasoning: string; +} + +export interface AnalysisResult { + summary: string; + hasInvestmentContent: boolean; + mentionedTargets?: MentionedTarget[]; + chainAnalysis?: string; + actionableSuggestion?: string; + moodScore?: number; +} + +export interface ReportData { + analysis: AnalysisResult; + postCount: { fb: number }; + posts: PostSummary[]; + isFallback: boolean; +} + +export interface Notifier { + readonly name: string; + send(report: ReportData): Promise; +} From c9a5e03b08afe1e26b595c1da2ec55bf1e4c978c Mon Sep 17 00:00:00 2001 From: CabLate Date: Sun, 12 Apr 2026 00:13:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=95=9F=E5=8B=95=E6=99=82=20confi?= =?UTF-8?q?g=20=E5=8D=8A=E9=85=8D=E5=B0=8D=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notifiers: 偵測 env var 只填一半(如有 TG_BOT_TOKEN 但缺 TG_CHANNEL_ID)時 console.warn - index.ts: 非 dry run 時預檢 LLM_API_KEY、TRANSCRIBER+GROQ_API_KEY - config.ts: loadConfig() 偵測 telegram botToken/channelId 半配對 Co-Authored-By: Claude Opus 4.6 --- src/config.ts | 7 +++++++ src/index.ts | 11 ++++++++++- src/notifiers/index.ts | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index cf9a7b8..61da9b2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; } diff --git a/src/index.ts b/src/index.ts index 9d252a5..293c31e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) @@ -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); diff --git a/src/notifiers/index.ts b/src/notifiers/index.ts index 1d725dd..de36fe4 100644 --- a/src/notifiers/index.ts +++ b/src/notifiers/index.ts @@ -18,6 +18,8 @@ export function createNotifiers(): Notifier[] { const tgChannelId = process.env.TG_CHANNEL_ID; if (tgToken && tgChannelId) { notifiers.push(createTelegramNotifier({ botToken: tgToken, channelId: tgChannelId })); + } else if (tgToken || tgChannelId) { + console.warn(`⚠ Telegram 設定不完整:${tgToken ? '缺少 TG_CHANNEL_ID' : '缺少 TG_BOT_TOKEN'},通知不會啟用`); } // Discord @@ -25,6 +27,8 @@ export function createNotifiers(): Notifier[] { const dcChannelId = process.env.DISCORD_CHANNEL_ID; if (dcToken && dcChannelId) { notifiers.push(createDiscordNotifier({ botToken: dcToken, channelId: dcChannelId })); + } else if (dcToken || dcChannelId) { + console.warn(`⚠ Discord 設定不完整:${dcToken ? '缺少 DISCORD_CHANNEL_ID' : '缺少 DISCORD_BOT_TOKEN'},通知不會啟用`); } // LINE @@ -32,6 +36,8 @@ export function createNotifiers(): Notifier[] { const lineTo = process.env.LINE_TO; if (lineToken && lineTo) { notifiers.push(createLineNotifier({ channelAccessToken: lineToken, to: lineTo })); + } else if (lineToken || lineTo) { + console.warn(`⚠ LINE 設定不完整:${lineToken ? '缺少 LINE_TO' : '缺少 LINE_CHANNEL_ACCESS_TOKEN'},通知不會啟用`); } return notifiers; From 24a0d22e8720165fbdfc4f1f2284b548888153b0 Mon Sep 17 00:00:00 2001 From: CabLate Date: Sun, 12 Apr 2026 00:21:31 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20README=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=A4=9A=E5=B9=B3=E5=8F=B0=E9=80=9A=E7=9F=A5=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 描述、常駐排程、.env 範例、費用估算均反映 Discord/LINE 支援 Co-Authored-By: Claude Opus 4.6 --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a1e9f64..c99907c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # banini-tracker -追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送,並自動追蹤預測準確度。 +追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、多平台即時推送(Telegram / Discord / LINE),並自動追蹤預測準確度。 - 辨識她提到的標的(個股、ETF、原物料) - 判斷她的操作(買入 / 被套 / 停損) @@ -34,7 +34,7 @@ > **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 手動執行分析 ## 快速開始(常駐排程) @@ -42,7 +42,7 @@ ```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 . @@ -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_... @@ -198,7 +206,7 @@ npx @cablate/banini-tracker push -f report.txt | LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 | | 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 | | 股價查詢(FinMind) | 免費 | 每日收盤後 | $0 | -| Telegram 推送 | 免費 | — | $0 | +| 通知推送(TG / DC / LINE) | 免費 | — | $0 | > CLI 模式搭配 Claude Code 使用不需 LLM 費用,Claude 自己分析。 > 回測歷史資料加日期篩選:~$7/千篇($5 基本 + $2 date filter add-on)。 From ba16e3babe1968590e890bfdae9d278e5753ed0f Mon Sep 17 00:00:00 2001 From: CabLate Date: Sun, 12 Apr 2026 00:22:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=20LINE=20?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=B2=BB=E7=94=A8=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LINE Messaging API Free plan 200 則/月,非完全免費 Co-Authored-By: Claude Opus 4.6 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c99907c..b81acda 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,8 @@ npx @cablate/banini-tracker push -f report.txt | LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 | | 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 | | 股價查詢(FinMind) | 免費 | 每日收盤後 | $0 | -| 通知推送(TG / DC / LINE) | 免費 | — | $0 | +| 通知推送(TG / DC) | 免費 | — | $0 | +| LINE 推送 | Free plan 200 則/月 | 同上 | $0(一般用量) | > CLI 模式搭配 Claude Code 使用不需 LLM 費用,Claude 自己分析。 > 回測歷史資料加日期篩選:~$7/千篇($5 基本 + $2 date filter add-on)。