@cablate/banini-tracker 2.0.15 → 2.0.17
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 +13 -4
- package/dist/cli.js +2 -2
- package/dist/config.js +7 -0
- package/dist/index.js +35 -23
- package/dist/notifiers/discord.d.ts +6 -0
- package/dist/notifiers/discord.js +88 -0
- package/dist/notifiers/format.d.ts +6 -0
- package/dist/notifiers/format.js +32 -0
- package/dist/notifiers/index.d.ts +25 -0
- package/dist/notifiers/index.js +55 -0
- package/dist/notifiers/line.d.ts +6 -0
- package/dist/notifiers/line.js +92 -0
- package/dist/notifiers/telegram.d.ts +7 -0
- package/dist/{telegram.js → notifiers/telegram.js} +36 -28
- package/dist/notifiers/types.d.ts +35 -0
- package/dist/notifiers/types.js +1 -0
- package/package.json +1 -1
- package/dist/telegram.d.ts +0 -32
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# banini-tracker
|
|
6
6
|
|
|
7
|
-
追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI
|
|
7
|
+
追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、多平台即時推送(Telegram / Discord / LINE),並自動追蹤預測準確度。
|
|
8
8
|
|
|
9
9
|
- 辨識她提到的標的(個股、ETF、原物料)
|
|
10
10
|
- 判斷她的操作(買入 / 被套 / 停損)
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
> **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。
|
|
35
35
|
|
|
36
36
|
支援兩種使用模式:
|
|
37
|
-
- **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + Telegram
|
|
37
|
+
- **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + 多平台推送(Telegram / Discord / LINE) + 預測追蹤
|
|
38
38
|
- **CLI 工具**:`npx @cablate/banini-tracker`,搭配 Claude Code 等 AI 手動執行分析
|
|
39
39
|
|
|
40
40
|
## 快速開始(常駐排程)
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
```bash
|
|
43
43
|
# 1. 複製設定
|
|
44
44
|
cp .env.example .env
|
|
45
|
-
#
|
|
45
|
+
# 填入必要項目,並設定至少一個通知管道(Telegram / Discord / LINE)
|
|
46
46
|
|
|
47
47
|
# 2. Docker 部署
|
|
48
48
|
docker build -t banini-tracker .
|
|
@@ -83,6 +83,14 @@ LLM_MODEL=MiniMaxAI/MiniMax-M2.5
|
|
|
83
83
|
TG_BOT_TOKEN=...
|
|
84
84
|
TG_CHANNEL_ID=-100...
|
|
85
85
|
|
|
86
|
+
# Discord Bot(選填,與 Telegram 擇一或同時使用)
|
|
87
|
+
DISCORD_BOT_TOKEN=...
|
|
88
|
+
DISCORD_CHANNEL_ID=...
|
|
89
|
+
|
|
90
|
+
# LINE(選填,與其他通知管道同時使用)
|
|
91
|
+
LINE_CHANNEL_ACCESS_TOKEN=...
|
|
92
|
+
LINE_TO=target_user_or_group_id
|
|
93
|
+
|
|
86
94
|
# 影片轉錄(選填,啟用後自動轉錄影片貼文)
|
|
87
95
|
TRANSCRIBER=groq
|
|
88
96
|
GROQ_API_KEY=gsk_...
|
|
@@ -198,7 +206,8 @@ npx @cablate/banini-tracker push -f report.txt
|
|
|
198
206
|
| LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 |
|
|
199
207
|
| 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 |
|
|
200
208
|
| 股價查詢(FinMind) | 免費 | 每日收盤後 | $0 |
|
|
201
|
-
|
|
|
209
|
+
| 通知推送(TG / DC) | 免費 | — | $0 |
|
|
210
|
+
| LINE 推送 | Free plan 200 則/月 | 同上 | $0(一般用量) |
|
|
202
211
|
|
|
203
212
|
> CLI 模式搭配 Claude Code 使用不需 LLM 費用,Claude 自己分析。
|
|
204
213
|
> 回測歷史資料加日期篩選:~$7/千篇($5 基本 + $2 date filter add-on)。
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { loadConfig, saveConfig, defaultConfig, getConfigPath, getSeenFile } from './config.js';
|
|
4
4
|
import { fetchFacebookPosts } from './facebook.js';
|
|
5
|
-
import {
|
|
5
|
+
import { sendTelegramDirect } from './notifiers/index.js';
|
|
6
6
|
import { filterNewPosts, markPostsSeen, listSeenIds, clearSeen } from './seen.js';
|
|
7
7
|
import { readFileSync } from 'fs';
|
|
8
8
|
import { createTranscriber, transcribeVideoPosts, isVideoPost } from './transcribe.js';
|
|
@@ -202,7 +202,7 @@ program
|
|
|
202
202
|
if (!text)
|
|
203
203
|
throw new Error('沒有訊息內容');
|
|
204
204
|
const parseMode = opts.parseMode === 'none' ? '' : opts.parseMode;
|
|
205
|
-
await
|
|
205
|
+
await sendTelegramDirect(config.telegram.botToken, config.telegram.channelId, text, parseMode);
|
|
206
206
|
console.error('Telegram 訊息已發送');
|
|
207
207
|
}
|
|
208
208
|
catch (err) {
|
package/dist/config.js
CHANGED
|
@@ -23,6 +23,13 @@ export function loadConfig() {
|
|
|
23
23
|
if (!raw.targets?.facebookPageUrl) {
|
|
24
24
|
throw new Error('設定檔缺少 targets.facebookPageUrl 設定');
|
|
25
25
|
}
|
|
26
|
+
// 半配對警告
|
|
27
|
+
if (raw.telegram) {
|
|
28
|
+
const { botToken, channelId } = raw.telegram;
|
|
29
|
+
if ((botToken && !channelId) || (!botToken && channelId)) {
|
|
30
|
+
console.warn(`⚠ telegram 設定不完整:${botToken ? '缺少 channelId' : '缺少 botToken'},push 指令將無法使用`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
26
33
|
return raw;
|
|
27
34
|
}
|
|
28
35
|
export function saveConfig(config) {
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { join } from 'path';
|
|
|
13
13
|
import cron from 'node-cron';
|
|
14
14
|
import { fetchFacebookPosts } from './facebook.js';
|
|
15
15
|
import { analyzePosts } from './analyze.js';
|
|
16
|
-
import {
|
|
16
|
+
import { createNotifiers } from './notifiers/index.js';
|
|
17
17
|
import { filterNewPosts as filterNew, markPostsSeen } from './seen.js';
|
|
18
18
|
import { withRetry } from './retry.js';
|
|
19
19
|
import { createTranscriber, transcribeVideoPosts } from './transcribe.js';
|
|
@@ -63,6 +63,14 @@ async function runInner(opts) {
|
|
|
63
63
|
const now = new Date().toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
|
|
64
64
|
console.log(`\n=== 巴逆逆反指標追蹤器 [${opts.label}] ${now} ===\n`);
|
|
65
65
|
const apifyToken = env('APIFY_TOKEN');
|
|
66
|
+
// 啟動預檢:提前警告設定問題,避免跑完抓取才炸
|
|
67
|
+
if (!opts.isDryRun && !process.env.LLM_API_KEY) {
|
|
68
|
+
console.warn('⚠ LLM_API_KEY 未設定,AI 分析將會失敗(可用 --dry 跳過分析)');
|
|
69
|
+
}
|
|
70
|
+
const transcriberType = (process.env.TRANSCRIBER ?? 'noop');
|
|
71
|
+
if (transcriberType === 'groq' && !process.env.GROQ_API_KEY) {
|
|
72
|
+
console.warn('⚠ TRANSCRIBER=groq 但 GROQ_API_KEY 未設定,影片轉錄將會失敗');
|
|
73
|
+
}
|
|
66
74
|
const allPosts = [];
|
|
67
75
|
// 1. 抓取 Facebook(含 retry)
|
|
68
76
|
try {
|
|
@@ -84,7 +92,6 @@ async function runInner(opts) {
|
|
|
84
92
|
return;
|
|
85
93
|
}
|
|
86
94
|
// 2.5. 影片轉錄(captionText 有值則跳過 Groq)
|
|
87
|
-
const transcriberType = (process.env.TRANSCRIBER ?? 'noop');
|
|
88
95
|
const transcriber = createTranscriber(transcriberType);
|
|
89
96
|
if (transcriber.name !== 'noop') {
|
|
90
97
|
const needsTranscribe = newPosts.filter((p) => !p.transcriptText);
|
|
@@ -220,30 +227,35 @@ async function runInner(opts) {
|
|
|
220
227
|
console.log('(本批貼文與投資無關)');
|
|
221
228
|
}
|
|
222
229
|
console.log('\n--- 僅供娛樂參考,不構成投資建議 ---\n');
|
|
223
|
-
// 7.
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
230
|
+
// 7. 多平台通知
|
|
231
|
+
const notifiers = createNotifiers();
|
|
232
|
+
if (notifiers.length > 0) {
|
|
233
|
+
const postSummaries = newPosts.map((p) => ({
|
|
234
|
+
source: p.source,
|
|
235
|
+
timestamp: new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }),
|
|
236
|
+
isToday: isToday(p.timestamp),
|
|
237
|
+
text: p.text.slice(0, 60),
|
|
238
|
+
url: p.url,
|
|
239
|
+
}));
|
|
240
|
+
const reportData = {
|
|
241
|
+
analysis,
|
|
242
|
+
postCount: { fb: fbCount },
|
|
243
|
+
posts: postSummaries,
|
|
244
|
+
isFallback: llmFailed,
|
|
245
|
+
};
|
|
246
|
+
const results = await Promise.allSettled(notifiers.map((n) => withRetry(() => n.send(reportData), { label: n.name, maxRetries: 3, baseDelayMs: 3000 })));
|
|
247
|
+
for (let i = 0; i < results.length; i++) {
|
|
248
|
+
const r = results[i];
|
|
249
|
+
if (r.status === 'fulfilled') {
|
|
250
|
+
console.log(`[${notifiers[i].name}] 通知已發送`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
console.error(`[${notifiers[i].name}] 發送失敗(已重試 3 次): ${r.reason instanceof Error ? r.reason.message : r.reason}`);
|
|
254
|
+
}
|
|
243
255
|
}
|
|
244
256
|
}
|
|
245
257
|
else {
|
|
246
|
-
console.log('[
|
|
258
|
+
console.log('[通知] 未設定任何通知管道,跳過');
|
|
247
259
|
}
|
|
248
260
|
// 8. 預測追蹤記錄
|
|
249
261
|
if (analysis.hasInvestmentContent && !llmFailed) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { splitMessage } from './format.js';
|
|
2
|
+
const API_BASE = 'https://discord.com/api/v10';
|
|
3
|
+
const MAX_LEN = 2000;
|
|
4
|
+
async function sendMessage(config, content) {
|
|
5
|
+
const url = `${API_BASE}/channels/${config.channelId}/messages`;
|
|
6
|
+
const res = await fetch(url, {
|
|
7
|
+
method: 'POST',
|
|
8
|
+
headers: {
|
|
9
|
+
'Content-Type': 'application/json',
|
|
10
|
+
Authorization: `Bot ${config.botToken}`,
|
|
11
|
+
},
|
|
12
|
+
body: JSON.stringify({ content }),
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
const respBody = await res.text().catch(() => '');
|
|
16
|
+
throw new Error(`Discord 發送失敗: ${res.status} ${respBody.slice(0, 200)}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function formatReport(report) {
|
|
20
|
+
const { analysis, postCount, posts } = report;
|
|
21
|
+
const lines = [];
|
|
22
|
+
if (report.isFallback) {
|
|
23
|
+
lines.push('**巴逆逆貼文速報**(LLM 分析失敗)');
|
|
24
|
+
lines.push('');
|
|
25
|
+
for (const p of posts) {
|
|
26
|
+
const todayTag = p.isToday ? ' [今天]' : '';
|
|
27
|
+
const preview = p.text.replace(/\n/g, ' ').slice(0, 80);
|
|
28
|
+
const link = p.url ? ` [原文](${p.url})` : '';
|
|
29
|
+
lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`);
|
|
30
|
+
}
|
|
31
|
+
lines.push('\n*LLM 服務暫時無法使用,僅列出原始貼文*');
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
lines.push('**巴逆逆反指標速報**');
|
|
35
|
+
lines.push(`來源:FB ${postCount.fb} 篇`);
|
|
36
|
+
lines.push('');
|
|
37
|
+
lines.push('**她的動態**');
|
|
38
|
+
for (const p of posts) {
|
|
39
|
+
const todayTag = p.isToday ? ' [今天]' : '';
|
|
40
|
+
const preview = p.text.replace(/\n/g, ' ').slice(0, 50);
|
|
41
|
+
const link = p.url ? ` [原文](${p.url})` : '';
|
|
42
|
+
lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}${link}`);
|
|
43
|
+
}
|
|
44
|
+
lines.push('');
|
|
45
|
+
lines.push(analysis.summary);
|
|
46
|
+
if (analysis.hasInvestmentContent) {
|
|
47
|
+
if (analysis.mentionedTargets?.length) {
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push('**提及標的**');
|
|
50
|
+
for (const t of analysis.mentionedTargets) {
|
|
51
|
+
const arrow = t.reverseView.includes('漲') || t.reverseView.includes('彈') ? '↑'
|
|
52
|
+
: t.reverseView.includes('跌') ? '↓' : '→';
|
|
53
|
+
lines.push(`${arrow} **${t.name}**(${t.type})`);
|
|
54
|
+
lines.push(` 她:${t.herAction} → 反指標:${t.reverseView} [${t.confidence}]`);
|
|
55
|
+
if (t.reasoning)
|
|
56
|
+
lines.push(` ${t.reasoning}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (analysis.chainAnalysis) {
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(`**連鎖推導**\n${analysis.chainAnalysis}`);
|
|
62
|
+
}
|
|
63
|
+
if (analysis.actionableSuggestion) {
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push(`**建議方向**\n${analysis.actionableSuggestion}`);
|
|
66
|
+
}
|
|
67
|
+
if (analysis.moodScore) {
|
|
68
|
+
lines.push(`\n冥燈指數:${analysis.moodScore}/10`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
lines.push('\n(本批貼文與投資無關)');
|
|
73
|
+
}
|
|
74
|
+
lines.push('\n*僅供娛樂參考,不構成投資建議*');
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
export function createDiscordNotifier(config) {
|
|
78
|
+
return {
|
|
79
|
+
name: 'Discord',
|
|
80
|
+
async send(report) {
|
|
81
|
+
const text = formatReport(report);
|
|
82
|
+
const chunks = splitMessage(text, MAX_LEN);
|
|
83
|
+
for (const chunk of chunks) {
|
|
84
|
+
await sendMessage(config, chunk);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function escapeHtml(text) {
|
|
2
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* 將長訊息分段,確保不超過平台字元上限。
|
|
6
|
+
* 優先在換行處切割,單行超長時強制切。
|
|
7
|
+
*/
|
|
8
|
+
export function splitMessage(text, maxLen) {
|
|
9
|
+
if (text.length <= maxLen)
|
|
10
|
+
return [text];
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let remaining = text;
|
|
13
|
+
while (remaining.length > 0) {
|
|
14
|
+
if (remaining.length <= maxLen) {
|
|
15
|
+
chunks.push(remaining);
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
// 找最後一個換行
|
|
19
|
+
let splitIdx = remaining.lastIndexOf('\n', maxLen);
|
|
20
|
+
if (splitIdx <= 0) {
|
|
21
|
+
// 沒有換行,找最後一個空格
|
|
22
|
+
splitIdx = remaining.lastIndexOf(' ', maxLen);
|
|
23
|
+
}
|
|
24
|
+
if (splitIdx <= 0) {
|
|
25
|
+
// 都沒有,強制切
|
|
26
|
+
splitIdx = maxLen;
|
|
27
|
+
}
|
|
28
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
29
|
+
remaining = remaining.slice(splitIdx).replace(/^\n/, '');
|
|
30
|
+
}
|
|
31
|
+
return chunks;
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Notifier } from './types.js';
|
|
2
|
+
export type { Notifier, ReportData, PostSummary, AnalysisResult } from './types.js';
|
|
3
|
+
export { sendTelegramDirect } from './telegram.js';
|
|
4
|
+
/**
|
|
5
|
+
* 讀取環境變數,建立所有已設定的 notifier。
|
|
6
|
+
* 常駐模式用:自動偵測哪些 channel 有設定。
|
|
7
|
+
*/
|
|
8
|
+
export declare function createNotifiers(): Notifier[];
|
|
9
|
+
/**
|
|
10
|
+
* 從 CLI config 建立 notifier(只支援已設定的 channel)。
|
|
11
|
+
*/
|
|
12
|
+
export declare function createNotifiersFromConfig(config: {
|
|
13
|
+
telegram?: {
|
|
14
|
+
botToken: string;
|
|
15
|
+
channelId: string;
|
|
16
|
+
};
|
|
17
|
+
discord?: {
|
|
18
|
+
botToken: string;
|
|
19
|
+
channelId: string;
|
|
20
|
+
};
|
|
21
|
+
line?: {
|
|
22
|
+
channelAccessToken: string;
|
|
23
|
+
to: string;
|
|
24
|
+
};
|
|
25
|
+
}): Notifier[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createTelegramNotifier } from './telegram.js';
|
|
2
|
+
import { createDiscordNotifier } from './discord.js';
|
|
3
|
+
import { createLineNotifier } from './line.js';
|
|
4
|
+
export { sendTelegramDirect } from './telegram.js';
|
|
5
|
+
/**
|
|
6
|
+
* 讀取環境變數,建立所有已設定的 notifier。
|
|
7
|
+
* 常駐模式用:自動偵測哪些 channel 有設定。
|
|
8
|
+
*/
|
|
9
|
+
export function createNotifiers() {
|
|
10
|
+
const notifiers = [];
|
|
11
|
+
// Telegram
|
|
12
|
+
const tgToken = process.env.TG_BOT_TOKEN;
|
|
13
|
+
const tgChannelId = process.env.TG_CHANNEL_ID;
|
|
14
|
+
if (tgToken && tgChannelId) {
|
|
15
|
+
notifiers.push(createTelegramNotifier({ botToken: tgToken, channelId: tgChannelId }));
|
|
16
|
+
}
|
|
17
|
+
else if (tgToken || tgChannelId) {
|
|
18
|
+
console.warn(`⚠ Telegram 設定不完整:${tgToken ? '缺少 TG_CHANNEL_ID' : '缺少 TG_BOT_TOKEN'},通知不會啟用`);
|
|
19
|
+
}
|
|
20
|
+
// Discord
|
|
21
|
+
const dcToken = process.env.DISCORD_BOT_TOKEN;
|
|
22
|
+
const dcChannelId = process.env.DISCORD_CHANNEL_ID;
|
|
23
|
+
if (dcToken && dcChannelId) {
|
|
24
|
+
notifiers.push(createDiscordNotifier({ botToken: dcToken, channelId: dcChannelId }));
|
|
25
|
+
}
|
|
26
|
+
else if (dcToken || dcChannelId) {
|
|
27
|
+
console.warn(`⚠ Discord 設定不完整:${dcToken ? '缺少 DISCORD_CHANNEL_ID' : '缺少 DISCORD_BOT_TOKEN'},通知不會啟用`);
|
|
28
|
+
}
|
|
29
|
+
// LINE
|
|
30
|
+
const lineToken = process.env.LINE_CHANNEL_ACCESS_TOKEN;
|
|
31
|
+
const lineTo = process.env.LINE_TO;
|
|
32
|
+
if (lineToken && lineTo) {
|
|
33
|
+
notifiers.push(createLineNotifier({ channelAccessToken: lineToken, to: lineTo }));
|
|
34
|
+
}
|
|
35
|
+
else if (lineToken || lineTo) {
|
|
36
|
+
console.warn(`⚠ LINE 設定不完整:${lineToken ? '缺少 LINE_TO' : '缺少 LINE_CHANNEL_ACCESS_TOKEN'},通知不會啟用`);
|
|
37
|
+
}
|
|
38
|
+
return notifiers;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 從 CLI config 建立 notifier(只支援已設定的 channel)。
|
|
42
|
+
*/
|
|
43
|
+
export function createNotifiersFromConfig(config) {
|
|
44
|
+
const notifiers = [];
|
|
45
|
+
if (config.telegram?.botToken && config.telegram?.channelId) {
|
|
46
|
+
notifiers.push(createTelegramNotifier(config.telegram));
|
|
47
|
+
}
|
|
48
|
+
if (config.discord?.botToken && config.discord?.channelId) {
|
|
49
|
+
notifiers.push(createDiscordNotifier(config.discord));
|
|
50
|
+
}
|
|
51
|
+
if (config.line?.channelAccessToken && config.line?.to) {
|
|
52
|
+
notifiers.push(createLineNotifier(config.line));
|
|
53
|
+
}
|
|
54
|
+
return notifiers;
|
|
55
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { splitMessage } from './format.js';
|
|
2
|
+
const API_BASE = 'https://api.line.me/v2/bot/message/push';
|
|
3
|
+
const MAX_LEN = 5000;
|
|
4
|
+
async function sendMessage(config, text) {
|
|
5
|
+
const res = await fetch(API_BASE, {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: {
|
|
8
|
+
'Content-Type': 'application/json',
|
|
9
|
+
Authorization: `Bearer ${config.channelAccessToken}`,
|
|
10
|
+
},
|
|
11
|
+
body: JSON.stringify({
|
|
12
|
+
to: config.to,
|
|
13
|
+
messages: [{ type: 'text', text }],
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const respBody = await res.text().catch(() => '');
|
|
18
|
+
throw new Error(`LINE 發送失敗: ${res.status} ${respBody.slice(0, 200)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function formatReport(report) {
|
|
22
|
+
const { analysis, postCount, posts } = report;
|
|
23
|
+
const lines = [];
|
|
24
|
+
if (report.isFallback) {
|
|
25
|
+
lines.push('[ 巴逆逆貼文速報 ](LLM 分析失敗)');
|
|
26
|
+
lines.push('');
|
|
27
|
+
for (const p of posts) {
|
|
28
|
+
const todayTag = p.isToday ? ' [今天]' : '';
|
|
29
|
+
const preview = p.text.replace(/\n/g, ' ').slice(0, 80);
|
|
30
|
+
lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}`);
|
|
31
|
+
if (p.url)
|
|
32
|
+
lines.push(` ${p.url}`);
|
|
33
|
+
}
|
|
34
|
+
lines.push('\nLLM 服務暫時無法使用,僅列出原始貼文');
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
}
|
|
37
|
+
lines.push('[ 巴逆逆反指標速報 ]');
|
|
38
|
+
lines.push(`來源:FB ${postCount.fb} 篇`);
|
|
39
|
+
lines.push('');
|
|
40
|
+
lines.push('[ 她的動態 ]');
|
|
41
|
+
for (const p of posts) {
|
|
42
|
+
const todayTag = p.isToday ? ' [今天]' : '';
|
|
43
|
+
const preview = p.text.replace(/\n/g, ' ').slice(0, 50);
|
|
44
|
+
lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}`);
|
|
45
|
+
if (p.url)
|
|
46
|
+
lines.push(` ${p.url}`);
|
|
47
|
+
}
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push(analysis.summary);
|
|
50
|
+
if (analysis.hasInvestmentContent) {
|
|
51
|
+
if (analysis.mentionedTargets?.length) {
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push('[ 提及標的 ]');
|
|
54
|
+
for (const t of analysis.mentionedTargets) {
|
|
55
|
+
const arrow = t.reverseView.includes('漲') || t.reverseView.includes('彈') ? '↑'
|
|
56
|
+
: t.reverseView.includes('跌') ? '↓' : '→';
|
|
57
|
+
lines.push(`${arrow} ${t.name}(${t.type})`);
|
|
58
|
+
lines.push(` 她:${t.herAction} → 反指標:${t.reverseView} [${t.confidence}]`);
|
|
59
|
+
if (t.reasoning)
|
|
60
|
+
lines.push(` ${t.reasoning}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (analysis.chainAnalysis) {
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push(`[ 連鎖推導 ]\n${analysis.chainAnalysis}`);
|
|
66
|
+
}
|
|
67
|
+
if (analysis.actionableSuggestion) {
|
|
68
|
+
lines.push('');
|
|
69
|
+
lines.push(`[ 建議方向 ]\n${analysis.actionableSuggestion}`);
|
|
70
|
+
}
|
|
71
|
+
if (analysis.moodScore) {
|
|
72
|
+
lines.push(`\n冥燈指數:${analysis.moodScore}/10`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
lines.push('\n(本批貼文與投資無關)');
|
|
77
|
+
}
|
|
78
|
+
lines.push('\n僅供娛樂參考,不構成投資建議');
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
export function createLineNotifier(config) {
|
|
82
|
+
return {
|
|
83
|
+
name: 'LINE',
|
|
84
|
+
async send(report) {
|
|
85
|
+
const text = formatReport(report);
|
|
86
|
+
const chunks = splitMessage(text, MAX_LEN);
|
|
87
|
+
for (const chunk of chunks) {
|
|
88
|
+
await sendMessage(config, chunk);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Notifier } from './types.js';
|
|
2
|
+
export interface TelegramConfig {
|
|
3
|
+
botToken: string;
|
|
4
|
+
channelId: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function createTelegramNotifier(config: TelegramConfig): Notifier;
|
|
7
|
+
export declare function sendTelegramDirect(botToken: string, channelId: string, text: string, parseMode?: 'HTML' | 'Markdown' | ''): Promise<void>;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { escapeHtml, splitMessage } from './format.js';
|
|
1
2
|
const API_BASE = 'https://api.telegram.org/bot';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const url = `${API_BASE}${botToken}/sendMessage`;
|
|
3
|
+
const MAX_LEN = 4096;
|
|
4
|
+
async function sendMessage(config, text, parseMode = 'HTML') {
|
|
5
|
+
const url = `${API_BASE}${config.botToken}/sendMessage`;
|
|
5
6
|
const body = {
|
|
6
|
-
chat_id: channelId,
|
|
7
|
+
chat_id: config.channelId,
|
|
7
8
|
text,
|
|
8
9
|
disable_web_page_preview: true,
|
|
9
10
|
};
|
|
@@ -19,14 +20,21 @@ export async function sendTelegramMessage(botToken, channelId, text, parseMode =
|
|
|
19
20
|
throw new Error(`Telegram 發送失敗: ${res.status} ${respBody.slice(0, 200)}`);
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
function escapeHtml(text) {
|
|
26
|
-
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
27
|
-
}
|
|
28
|
-
export function formatReport(analysis, postCount, posts) {
|
|
23
|
+
function formatReport(report) {
|
|
24
|
+
const { analysis, postCount, posts } = report;
|
|
29
25
|
const lines = [];
|
|
26
|
+
if (report.isFallback) {
|
|
27
|
+
lines.push('<b>巴逆逆貼文速報</b>(LLM 分析失敗)');
|
|
28
|
+
lines.push('');
|
|
29
|
+
for (const p of posts) {
|
|
30
|
+
const todayTag = p.isToday ? ' [今天]' : '';
|
|
31
|
+
const preview = escapeHtml(p.text.replace(/\n/g, ' ').slice(0, 80));
|
|
32
|
+
const link = p.url ? ` <a href="${p.url}">原文</a>` : '';
|
|
33
|
+
lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`);
|
|
34
|
+
}
|
|
35
|
+
lines.push('\n<i>LLM 服務暫時無法使用,僅列出原始貼文</i>');
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
30
38
|
lines.push('<b>巴逆逆反指標速報</b>');
|
|
31
39
|
lines.push(`來源:FB ${postCount.fb} 篇`);
|
|
32
40
|
lines.push('');
|
|
@@ -44,11 +52,8 @@ export function formatReport(analysis, postCount, posts) {
|
|
|
44
52
|
lines.push('');
|
|
45
53
|
lines.push('<b>提及標的</b>');
|
|
46
54
|
for (const t of analysis.mentionedTargets) {
|
|
47
|
-
const arrow = t.reverseView.includes('漲') || t.reverseView.includes('彈')
|
|
48
|
-
? '
|
|
49
|
-
: t.reverseView.includes('跌')
|
|
50
|
-
? '↓'
|
|
51
|
-
: '→';
|
|
55
|
+
const arrow = t.reverseView.includes('漲') || t.reverseView.includes('彈') ? '↑'
|
|
56
|
+
: t.reverseView.includes('跌') ? '↓' : '→';
|
|
52
57
|
lines.push(`${arrow} <b>${escapeHtml(t.name)}</b>(${escapeHtml(t.type)})`);
|
|
53
58
|
lines.push(` 她:${escapeHtml(t.herAction)} → 反指標:${escapeHtml(t.reverseView)} [${escapeHtml(t.confidence)}]`);
|
|
54
59
|
if (t.reasoning)
|
|
@@ -73,16 +78,19 @@ export function formatReport(analysis, postCount, posts) {
|
|
|
73
78
|
lines.push('\n<i>僅供娛樂參考,不構成投資建議</i>');
|
|
74
79
|
return lines.join('\n');
|
|
75
80
|
}
|
|
76
|
-
export function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
export function createTelegramNotifier(config) {
|
|
82
|
+
return {
|
|
83
|
+
name: 'Telegram',
|
|
84
|
+
async send(report) {
|
|
85
|
+
const text = formatReport(report);
|
|
86
|
+
const chunks = splitMessage(text, MAX_LEN);
|
|
87
|
+
for (const chunk of chunks) {
|
|
88
|
+
await sendMessage(config, chunk);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// CLI 用(直接傳參數發送純文字)
|
|
94
|
+
export async function sendTelegramDirect(botToken, channelId, text, parseMode = 'HTML') {
|
|
95
|
+
return sendMessage({ botToken, channelId }, text, parseMode);
|
|
88
96
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface PostSummary {
|
|
2
|
+
source: 'facebook';
|
|
3
|
+
timestamp: string;
|
|
4
|
+
isToday: boolean;
|
|
5
|
+
text: string;
|
|
6
|
+
url: string;
|
|
7
|
+
}
|
|
8
|
+
export interface MentionedTarget {
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
herAction: string;
|
|
12
|
+
reverseView: string;
|
|
13
|
+
confidence: string;
|
|
14
|
+
reasoning: string;
|
|
15
|
+
}
|
|
16
|
+
export interface AnalysisResult {
|
|
17
|
+
summary: string;
|
|
18
|
+
hasInvestmentContent: boolean;
|
|
19
|
+
mentionedTargets?: MentionedTarget[];
|
|
20
|
+
chainAnalysis?: string;
|
|
21
|
+
actionableSuggestion?: string;
|
|
22
|
+
moodScore?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ReportData {
|
|
25
|
+
analysis: AnalysisResult;
|
|
26
|
+
postCount: {
|
|
27
|
+
fb: number;
|
|
28
|
+
};
|
|
29
|
+
posts: PostSummary[];
|
|
30
|
+
isFallback: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface Notifier {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
send(report: ReportData): Promise<void>;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
package/dist/telegram.d.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
export declare function sendTelegramMessage(botToken: string, channelId: string, text: string, parseMode?: 'HTML' | 'Markdown' | ''): Promise<void>;
|
|
2
|
-
export interface TelegramConfig {
|
|
3
|
-
botToken: string;
|
|
4
|
-
channelId: string;
|
|
5
|
-
}
|
|
6
|
-
export declare function sendTelegramMessageWithConfig(config: TelegramConfig, text: string): Promise<void>;
|
|
7
|
-
interface PostSummary {
|
|
8
|
-
source: 'facebook';
|
|
9
|
-
timestamp: string;
|
|
10
|
-
isToday: boolean;
|
|
11
|
-
text: string;
|
|
12
|
-
url: string;
|
|
13
|
-
}
|
|
14
|
-
export declare function formatReport(analysis: {
|
|
15
|
-
summary: string;
|
|
16
|
-
hasInvestmentContent: boolean;
|
|
17
|
-
mentionedTargets?: {
|
|
18
|
-
name: string;
|
|
19
|
-
type: string;
|
|
20
|
-
herAction: string;
|
|
21
|
-
reverseView: string;
|
|
22
|
-
confidence: string;
|
|
23
|
-
reasoning: string;
|
|
24
|
-
}[];
|
|
25
|
-
chainAnalysis?: string;
|
|
26
|
-
actionableSuggestion?: string;
|
|
27
|
-
moodScore?: number;
|
|
28
|
-
}, postCount: {
|
|
29
|
-
fb: number;
|
|
30
|
-
}, posts: PostSummary[]): string;
|
|
31
|
-
export declare function formatFallbackReport(posts: PostSummary[]): string;
|
|
32
|
-
export {};
|