@cablate/banini-tracker 2.0.19 → 2.0.21
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/LICENSE +661 -21
- package/README.md +78 -143
- package/dist/auth.d.ts +12 -0
- package/dist/auth.js +76 -0
- package/dist/cli.js +11 -0
- package/dist/config-store.d.ts +21 -0
- package/dist/config-store.js +90 -0
- package/dist/index.js +12 -5
- package/dist/notifiers/index.d.ts +2 -2
- package/dist/notifiers/index.js +9 -8
- package/dist/web.d.ts +1 -0
- package/dist/web.js +626 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
# banini-tracker
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
> **AGPL-3.0** — 使用、修改或部署本專案,必須標明原作者 **[cablate](https://github.com/cablate)**、附上[本 repo 連結](https://github.com/cablate/banini-tracker),並以相同授權公開原始碼。網路服務部署亦同。
|
|
8
|
+
>
|
|
9
|
+
> 本專案於 2026/04/13 從 MIT 切換至 AGPL-3.0。此日期之前取得的版本仍適用 MIT 授權。
|
|
10
|
+
|
|
11
|
+
追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 AI 反指標分析、多平台即時推送(Telegram / Discord / LINE),並自動追蹤預測準確度。
|
|
8
12
|
|
|
9
13
|
- 辨識她提到的標的(個股、ETF、原物料)
|
|
10
|
-
- 判斷她的操作(買入 / 被套 / 停損)
|
|
11
14
|
- 反轉推導(她停損 → 可能反彈、她買入 → 可能下跌)
|
|
12
15
|
- 推導連鎖效應(油價跌 → 製造業利多 → 電子股受惠)
|
|
13
16
|
- 自動記錄預測,追蹤 5 個交易日的實際走勢
|
|
@@ -31,78 +34,76 @@
|
|
|
31
34
|
|
|
32
35
|
---
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
## 快速開始
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
- **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + 多平台推送(Telegram / Discord / LINE) + 預測追蹤
|
|
38
|
-
- **CLI 工具**:`npx @cablate/banini-tracker`,搭配 Claude Code 等 AI 手動執行分析
|
|
39
|
+
四種使用方式,按推薦順序:
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
### 1. Zeabur 一鍵部署(推薦)
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
# 1. 複製設定
|
|
44
|
-
cp .env.example .env
|
|
45
|
-
# 填入必要項目,並設定至少一個通知管道(Telegram / Discord / LINE)
|
|
43
|
+
[](https://zeabur.com/templates/banini-tracker)
|
|
46
44
|
|
|
47
|
-
|
|
45
|
+
部署後打開分配的網域,首次進入設定管理員帳密,然後在 Web UI 填入 API key 和通知管道。不需要手動設定環境變數。
|
|
46
|
+
|
|
47
|
+
### 2. Docker
|
|
48
|
+
|
|
49
|
+
```bash
|
|
48
50
|
docker build -t banini-tracker .
|
|
49
|
-
docker run -d --name banini --env-file .env -v banini-data:/data banini-tracker
|
|
51
|
+
docker run -d --name banini --env-file .env -v banini-data:/data -p 3000:3000 banini-tracker
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
部署後打開 `http://localhost:3000` 進入設定頁面。也可以直接用 `.env` 檔設定環境變數(見下方)。
|
|
55
|
+
|
|
56
|
+
### 3. npx 直接啟動
|
|
50
57
|
|
|
51
|
-
|
|
58
|
+
```bash
|
|
59
|
+
npx @cablate/banini-tracker serve
|
|
60
|
+
npx @cablate/banini-tracker serve --port 8080
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
不需 Docker,直接在本機啟動常駐服務(排程 + Web 設定頁面)。打開 `http://localhost:3000` 進入設定。適合有 Node.js 環境的使用者。
|
|
64
|
+
|
|
65
|
+
### 4. 本地開發
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
cp .env.example .env # 填入必要設定
|
|
52
69
|
npm install && npm run start
|
|
53
70
|
```
|
|
54
71
|
|
|
55
|
-
|
|
72
|
+
## 排程規則
|
|
56
73
|
|
|
57
74
|
| 排程 | 時間 | 說明 |
|
|
58
75
|
|------|------|------|
|
|
59
|
-
| 早晨補漏 | 每天 08:00 |
|
|
60
|
-
| 盤中 | 週一~五 09:07-13:07 每 30 分 |
|
|
61
|
-
| 追蹤更新 | 週一~五 15:00 |
|
|
62
|
-
| 盤後 | 每天 23:03 |
|
|
76
|
+
| 早晨補漏 | 每天 08:00 | 抓前一晚的貼文(3 篇) |
|
|
77
|
+
| 盤中 | 週一~五 09:07-13:07 每 30 分 | 即時追蹤(1 篇) |
|
|
78
|
+
| 追蹤更新 | 週一~五 15:00 | 收盤後更新預測追蹤 |
|
|
79
|
+
| 盤後 | 每天 23:03 | 當日彙整(3 篇) |
|
|
63
80
|
|
|
64
|
-
|
|
81
|
+
## 設定
|
|
65
82
|
|
|
66
|
-
###
|
|
83
|
+
### Web UI(Zeabur / Docker)
|
|
67
84
|
|
|
68
|
-
|
|
69
|
-
|------|------|
|
|
70
|
-
| `npm run start` | 常駐排程模式(全部排程自動跑) |
|
|
71
|
-
| `npm run dev` | 單次執行(FB 3 篇) |
|
|
72
|
-
| `npm run dry` | 只抓取,不呼叫 LLM |
|
|
73
|
-
| `npm run market` | 盤中模式(FB 1 篇) |
|
|
74
|
-
| `npm run evening` | 盤後模式(FB 3 篇) |
|
|
85
|
+
常駐模式會在 port 3000 啟動設定頁面。首次進入建立管理員帳密,之後登入即可修改設定。修改後下次排程執行自動生效,不需重啟。
|
|
75
86
|
|
|
76
|
-
###
|
|
87
|
+
### 環境變數(.env)
|
|
77
88
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
DISCORD_BOT_TOKEN
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
LINE_TO=target_user_or_group_id
|
|
93
|
-
|
|
94
|
-
# 影片轉錄(選填,啟用後自動轉錄影片貼文)
|
|
95
|
-
TRANSCRIBER=groq
|
|
96
|
-
GROQ_API_KEY=gsk_...
|
|
97
|
-
|
|
98
|
-
# FinMind API(選填,免費可用,註冊可提高額度)
|
|
99
|
-
FINMIND_TOKEN=...
|
|
100
|
-
|
|
101
|
-
# 資料目錄(Docker 建議掛載 /data)
|
|
102
|
-
DATA_DIR=/data
|
|
103
|
-
```
|
|
89
|
+
如果不使用 Web UI,也可以直接設定環境變數:
|
|
90
|
+
|
|
91
|
+
| 變數 | 必填 | 說明 |
|
|
92
|
+
|------|------|------|
|
|
93
|
+
| `APIFY_TOKEN` | 是 | Apify API token |
|
|
94
|
+
| `LLM_API_KEY` | 是 | LLM API key |
|
|
95
|
+
| `LLM_BASE_URL` | — | 預設 DeepInfra |
|
|
96
|
+
| `LLM_MODEL` | — | 預設 MiniMax-M2.5 |
|
|
97
|
+
| `TG_BOT_TOKEN` + `TG_CHANNEL_ID` | 至少一組 | Telegram 通知 |
|
|
98
|
+
| `DISCORD_BOT_TOKEN` + `DISCORD_CHANNEL_ID` | 至少一組 | Discord 通知 |
|
|
99
|
+
| `LINE_CHANNEL_ACCESS_TOKEN` + `LINE_TO` | 至少一組 | LINE 通知(Free plan 200 則/月) |
|
|
100
|
+
| `TRANSCRIBER` | — | `noop`(預設)或 `groq` |
|
|
101
|
+
| `GROQ_API_KEY` | — | Groq Whisper 影片轉錄用 |
|
|
102
|
+
| `FINMIND_TOKEN` | — | 股價查詢(免費可用) |
|
|
104
103
|
|
|
105
|
-
|
|
104
|
+
Web UI 和環境變數可以混用,Web UI 的設定優先。
|
|
105
|
+
|
|
106
|
+
## 預測追蹤
|
|
106
107
|
|
|
107
108
|
LLM 分析出標的後,系統自動:
|
|
108
109
|
|
|
@@ -111,113 +112,47 @@ LLM 分析出標的後,系統自動:
|
|
|
111
112
|
3. **追蹤 5 個交易日**:每天 15:00 收盤後抓 OHLC,記錄漲跌幅
|
|
112
113
|
4. **同股票取代**:新預測自動取代同標的舊預測(supersede 機制)
|
|
113
114
|
|
|
114
|
-
勝敗判定在查詢時決定,支援多維度分析(不同持有天數、信心度分群、操作類型)。
|
|
115
|
-
|
|
116
|
-
### 資料儲存
|
|
117
|
-
|
|
118
|
-
使用 SQLite(better-sqlite3),資料表:
|
|
119
|
-
|
|
120
|
-
| 表 | 用途 |
|
|
121
|
-
|----|------|
|
|
122
|
-
| `posts` | 所有貼文原文(即時 + 歷史回測統一來源) |
|
|
123
|
-
| `predictions` | 預測記錄(標的、方向、基準價、狀態) |
|
|
124
|
-
| `price_snapshots` | 每日 OHLC 快照(5 天追蹤期) |
|
|
125
|
-
|
|
126
|
-
資料庫位置:`$DATA_DIR/banini.db`(Docker 掛載 `/data`,本地 `~/.banini-tracker/`)
|
|
127
|
-
|
|
128
115
|
### 公開資料集
|
|
129
116
|
|
|
130
|
-
[`data/banini-public.db`](data/banini-public.db)
|
|
117
|
+
[`data/banini-public.db`](data/banini-public.db) 提供去識別化的預測資料(345 筆預測 + 價格快照),不含原始貼文。
|
|
131
118
|
|
|
132
119
|
```bash
|
|
133
|
-
# 快速查看
|
|
134
120
|
sqlite3 data/banini-public.db "SELECT symbol_name, reverse_view, base_price, status FROM predictions LIMIT 10"
|
|
135
121
|
```
|
|
136
122
|
|
|
137
|
-
## CLI
|
|
123
|
+
## CLI 工具
|
|
138
124
|
|
|
139
|
-
不需 clone repo
|
|
125
|
+
不需 clone repo,搭配 Claude Code 等 AI 使用:
|
|
140
126
|
|
|
141
127
|
```bash
|
|
142
|
-
#
|
|
143
|
-
npx @cablate/banini-tracker
|
|
144
|
-
--apify-token YOUR_APIFY_TOKEN \
|
|
145
|
-
--tg-bot-token YOUR_TG_BOT_TOKEN \
|
|
146
|
-
--tg-channel-id YOUR_TG_CHANNEL_ID
|
|
128
|
+
# 啟動常駐服務(排程 + Web UI)
|
|
129
|
+
npx @cablate/banini-tracker serve
|
|
147
130
|
|
|
148
|
-
#
|
|
149
|
-
npx @cablate/banini-tracker
|
|
131
|
+
# 初始化 CLI 設定
|
|
132
|
+
npx @cablate/banini-tracker init --apify-token YOUR_TOKEN
|
|
150
133
|
|
|
151
|
-
#
|
|
152
|
-
npx @cablate/banini-tracker fetch
|
|
134
|
+
# 抓取貼文
|
|
135
|
+
npx @cablate/banini-tracker fetch -n 3 --mark-seen
|
|
153
136
|
|
|
154
|
-
#
|
|
137
|
+
# 推送到 Telegram
|
|
155
138
|
npx @cablate/banini-tracker push -f report.txt
|
|
156
139
|
```
|
|
157
140
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
| 指令 | 說明 |
|
|
161
|
-
|------|------|
|
|
162
|
-
| `init` | 初始化設定檔(`~/.banini-tracker.json`) |
|
|
163
|
-
| `config` | 顯示目前設定 |
|
|
164
|
-
| `fetch` | 抓取貼文,輸出 JSON 到 stdout |
|
|
165
|
-
| `push` | 推送訊息到 Telegram |
|
|
166
|
-
| `seen list` | 列出已讀貼文 ID |
|
|
167
|
-
| `seen mark <id...>` | 標記貼文為已讀 |
|
|
168
|
-
| `seen clear` | 清空已讀紀錄 |
|
|
169
|
-
|
|
170
|
-
### fetch 選項
|
|
171
|
-
|
|
172
|
-
```
|
|
173
|
-
-s, --source <source> 來源:fb(預設 fb)
|
|
174
|
-
-n, --limit <n> 每個來源抓幾篇(預設 3)
|
|
175
|
-
--since <date> 只抓此時間之後的貼文(YYYY-MM-DD / ISO 時間戳 / 相對時間如 "2 months")
|
|
176
|
-
--until <date> 只抓此時間之前的貼文
|
|
177
|
-
--no-dedup 不去重
|
|
178
|
-
--mark-seen 輸出後自動標記已讀
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
### push 選項
|
|
182
|
-
|
|
183
|
-
```
|
|
184
|
-
-m, --message <text> 直接帶訊息
|
|
185
|
-
-f, --file <path> 從檔案讀取(推薦多行內容用這個)
|
|
186
|
-
--parse-mode <mode> HTML / Markdown / none(預設 HTML)
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
不帶 `-m` 或 `-f` 時從 stdin 讀取。
|
|
190
|
-
|
|
191
|
-
### 搭配 Claude Code 使用
|
|
192
|
-
|
|
193
|
-
在 Claude Code 的 skill 中,Claude 自己就是分析引擎:
|
|
194
|
-
|
|
195
|
-
1. `fetch` 抓貼文 → Claude 讀 JSON
|
|
196
|
-
2. Claude 分析 + WebSearch 查最新走勢
|
|
197
|
-
3. Claude 組報告 → `push -f` 推送 Telegram
|
|
141
|
+
> **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。
|
|
198
142
|
|
|
199
|
-
|
|
143
|
+
完整指令說明見 `npx @cablate/banini-tracker --help`。
|
|
200
144
|
|
|
201
145
|
## 費用估算
|
|
202
146
|
|
|
203
|
-
| 項目 |
|
|
204
|
-
|
|
205
|
-
| Facebook 抓取(Apify) | ~$
|
|
206
|
-
| LLM
|
|
207
|
-
|
|
|
208
|
-
|
|
|
209
|
-
|
|
|
210
|
-
| LINE 推送 | Free plan 200 則/月 | 同上 | $0(一般用量) |
|
|
211
|
-
|
|
212
|
-
> CLI 模式搭配 Claude Code 使用不需 LLM 費用,Claude 自己分析。
|
|
213
|
-
> 回測歷史資料加日期篩選:~$7/千篇($5 基本 + $2 date filter add-on)。
|
|
214
|
-
|
|
215
|
-
## 為什麼只用 Facebook?
|
|
216
|
-
|
|
217
|
-
早期版本同時支援 Threads 和 Facebook 爬取,後來基於兩個原因移除了 Threads:
|
|
147
|
+
| 項目 | 月估算 |
|
|
148
|
+
|------|--------|
|
|
149
|
+
| Facebook 抓取(Apify) | ~$1.35(~270 篇) |
|
|
150
|
+
| LLM 分析 | 依模型定價 |
|
|
151
|
+
| 通知推送(TG / DC) | 免費 |
|
|
152
|
+
| LINE 推送 | Free plan 200 則/月 |
|
|
153
|
+
| 股價查詢(FinMind) | 免費 |
|
|
218
154
|
|
|
219
|
-
|
|
220
|
-
2. **FB 參考價值更高**:巴逆逆的投資相關貼文(持倉截圖、操作心得)主要發在 Facebook 粉專,Threads 多為生活日常,反指標參考價值較低
|
|
155
|
+
> CLI 模式搭配 Claude Code 不需 LLM 費用,Claude 自己分析。
|
|
221
156
|
|
|
222
157
|
## Star History
|
|
223
158
|
|
|
@@ -235,4 +170,4 @@ npx @cablate/banini-tracker push -f report.txt
|
|
|
235
170
|
|
|
236
171
|
## License
|
|
237
172
|
|
|
238
|
-
|
|
173
|
+
[AGPL-3.0](LICENSE)
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Ensure auth table exists */
|
|
2
|
+
export declare function initAuthTable(): void;
|
|
3
|
+
/** Check if initial setup is done */
|
|
4
|
+
export declare function isInitialized(): boolean;
|
|
5
|
+
/** First-run: create admin account */
|
|
6
|
+
export declare function setupAdmin(username: string, password: string): void;
|
|
7
|
+
/** Verify credentials, return session token or null */
|
|
8
|
+
export declare function login(username: string, password: string): string | null;
|
|
9
|
+
/** Validate session token */
|
|
10
|
+
export declare function validateSession(token: string): boolean;
|
|
11
|
+
/** Logout */
|
|
12
|
+
export declare function logout(token: string): void;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth module: first-run setup + login + session token.
|
|
3
|
+
* Password hashed with scrypt, session token in memory.
|
|
4
|
+
*/
|
|
5
|
+
import { scryptSync, randomBytes, timingSafeEqual } from 'crypto';
|
|
6
|
+
import { getDb } from './db.js';
|
|
7
|
+
const SCRYPT_KEYLEN = 64;
|
|
8
|
+
/** Ensure auth table exists */
|
|
9
|
+
export function initAuthTable() {
|
|
10
|
+
const db = getDb();
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS auth (
|
|
13
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
14
|
+
username TEXT NOT NULL,
|
|
15
|
+
password_hash TEXT NOT NULL
|
|
16
|
+
)
|
|
17
|
+
`);
|
|
18
|
+
}
|
|
19
|
+
/** Check if initial setup is done */
|
|
20
|
+
export function isInitialized() {
|
|
21
|
+
initAuthTable();
|
|
22
|
+
const db = getDb();
|
|
23
|
+
const row = db.prepare('SELECT id FROM auth WHERE id = 1').get();
|
|
24
|
+
return !!row;
|
|
25
|
+
}
|
|
26
|
+
/** First-run: create admin account */
|
|
27
|
+
export function setupAdmin(username, password) {
|
|
28
|
+
if (isInitialized())
|
|
29
|
+
throw new Error('already initialized');
|
|
30
|
+
if (!username || !password)
|
|
31
|
+
throw new Error('username and password required');
|
|
32
|
+
if (password.length < 6)
|
|
33
|
+
throw new Error('password must be at least 6 characters');
|
|
34
|
+
const salt = randomBytes(16).toString('hex');
|
|
35
|
+
const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex');
|
|
36
|
+
const stored = `${salt}:${hash}`;
|
|
37
|
+
const db = getDb();
|
|
38
|
+
db.prepare('INSERT INTO auth (id, username, password_hash) VALUES (1, ?, ?)').run(username, stored);
|
|
39
|
+
}
|
|
40
|
+
/** Verify credentials, return session token or null */
|
|
41
|
+
export function login(username, password) {
|
|
42
|
+
initAuthTable();
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const row = db.prepare('SELECT username, password_hash FROM auth WHERE id = 1').get();
|
|
45
|
+
if (!row)
|
|
46
|
+
return null;
|
|
47
|
+
if (row.username !== username)
|
|
48
|
+
return null;
|
|
49
|
+
const [salt, storedHash] = row.password_hash.split(':');
|
|
50
|
+
const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex');
|
|
51
|
+
const storedBuf = Buffer.from(storedHash, 'hex');
|
|
52
|
+
const hashBuf = Buffer.from(hash, 'hex');
|
|
53
|
+
if (!timingSafeEqual(storedBuf, hashBuf))
|
|
54
|
+
return null;
|
|
55
|
+
const token = randomBytes(32).toString('hex');
|
|
56
|
+
sessions.set(token, { username, createdAt: Date.now() });
|
|
57
|
+
return token;
|
|
58
|
+
}
|
|
59
|
+
/** Validate session token */
|
|
60
|
+
export function validateSession(token) {
|
|
61
|
+
const session = sessions.get(token);
|
|
62
|
+
if (!session)
|
|
63
|
+
return false;
|
|
64
|
+
// 24h expiry
|
|
65
|
+
if (Date.now() - session.createdAt > 24 * 60 * 60 * 1000) {
|
|
66
|
+
sessions.delete(token);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
/** Logout */
|
|
72
|
+
export function logout(token) {
|
|
73
|
+
sessions.delete(token);
|
|
74
|
+
}
|
|
75
|
+
// In-memory session store (container restart = re-login, acceptable)
|
|
76
|
+
const sessions = new Map();
|
package/dist/cli.js
CHANGED
|
@@ -171,6 +171,17 @@ seenCmd
|
|
|
171
171
|
clearSeen();
|
|
172
172
|
console.error(`已清空: ${getSeenFile()}`);
|
|
173
173
|
});
|
|
174
|
+
// ── serve ────────────────────────────────────────────────
|
|
175
|
+
program
|
|
176
|
+
.command('serve')
|
|
177
|
+
.description('啟動常駐服務(排程 + Web 設定頁面)')
|
|
178
|
+
.option('-p, --port <port>', 'Web UI port', '3000')
|
|
179
|
+
.action(async (opts) => {
|
|
180
|
+
if (opts.port)
|
|
181
|
+
process.env.WEB_PORT = opts.port;
|
|
182
|
+
process.argv.push('--cron');
|
|
183
|
+
await import('./index.js');
|
|
184
|
+
});
|
|
174
185
|
// ── push ─────────────────────────────────────────────────
|
|
175
186
|
program
|
|
176
187
|
.command('push')
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ConfigEntry {
|
|
2
|
+
key: string;
|
|
3
|
+
value: string;
|
|
4
|
+
}
|
|
5
|
+
declare const CONFIG_KEYS: readonly ["APIFY_TOKEN", "LLM_BASE_URL", "LLM_API_KEY", "LLM_MODEL", "TG_BOT_TOKEN", "TG_CHANNEL_ID", "DISCORD_BOT_TOKEN", "DISCORD_CHANNEL_ID", "LINE_CHANNEL_ACCESS_TOKEN", "LINE_TO", "TRANSCRIBER", "GROQ_API_KEY", "FINMIND_TOKEN"];
|
|
6
|
+
export type ConfigKey = typeof CONFIG_KEYS[number];
|
|
7
|
+
/** Ensure config table exists */
|
|
8
|
+
export declare function initConfigTable(): void;
|
|
9
|
+
/** Get a single config value: DB > env > default */
|
|
10
|
+
export declare function getConfig(key: ConfigKey): string;
|
|
11
|
+
/** Get all config as object */
|
|
12
|
+
export declare function getAllConfig(): Record<ConfigKey, string>;
|
|
13
|
+
/** Set a single config value in DB */
|
|
14
|
+
export declare function setConfig(key: ConfigKey, value: string): void;
|
|
15
|
+
/** Batch set multiple config values */
|
|
16
|
+
export declare function setConfigs(entries: Partial<Record<ConfigKey, string>>): void;
|
|
17
|
+
/** Current cache version, can be used for hot-reload detection */
|
|
18
|
+
export declare function getConfigVersion(): number;
|
|
19
|
+
/** Valid config keys list */
|
|
20
|
+
export declare function getConfigKeys(): readonly string[];
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB-backed config store with env fallback + in-memory cache.
|
|
3
|
+
* Web UI writes here, cron reads here on every run().
|
|
4
|
+
*/
|
|
5
|
+
import { getDb } from './db.js';
|
|
6
|
+
const CONFIG_KEYS = [
|
|
7
|
+
'APIFY_TOKEN',
|
|
8
|
+
'LLM_BASE_URL',
|
|
9
|
+
'LLM_API_KEY',
|
|
10
|
+
'LLM_MODEL',
|
|
11
|
+
'TG_BOT_TOKEN',
|
|
12
|
+
'TG_CHANNEL_ID',
|
|
13
|
+
'DISCORD_BOT_TOKEN',
|
|
14
|
+
'DISCORD_CHANNEL_ID',
|
|
15
|
+
'LINE_CHANNEL_ACCESS_TOKEN',
|
|
16
|
+
'LINE_TO',
|
|
17
|
+
'TRANSCRIBER',
|
|
18
|
+
'GROQ_API_KEY',
|
|
19
|
+
'FINMIND_TOKEN',
|
|
20
|
+
];
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
LLM_BASE_URL: 'https://api.deepinfra.com/v1/openai',
|
|
23
|
+
LLM_MODEL: 'MiniMaxAI/MiniMax-M2.5',
|
|
24
|
+
TRANSCRIBER: 'noop',
|
|
25
|
+
};
|
|
26
|
+
let cache = null;
|
|
27
|
+
let cacheVersion = 0;
|
|
28
|
+
/** Ensure config table exists */
|
|
29
|
+
export function initConfigTable() {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
db.exec(`
|
|
32
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
33
|
+
key TEXT PRIMARY KEY,
|
|
34
|
+
value TEXT NOT NULL DEFAULT ''
|
|
35
|
+
)
|
|
36
|
+
`);
|
|
37
|
+
}
|
|
38
|
+
/** Get a single config value: DB > env > default */
|
|
39
|
+
export function getConfig(key) {
|
|
40
|
+
loadCache();
|
|
41
|
+
return cache.get(key) || process.env[key] || DEFAULTS[key] || '';
|
|
42
|
+
}
|
|
43
|
+
/** Get all config as object */
|
|
44
|
+
export function getAllConfig() {
|
|
45
|
+
loadCache();
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const key of CONFIG_KEYS) {
|
|
48
|
+
result[key] = cache.get(key) || process.env[key] || DEFAULTS[key] || '';
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
/** Set a single config value in DB */
|
|
53
|
+
export function setConfig(key, value) {
|
|
54
|
+
const db = getDb();
|
|
55
|
+
initConfigTable();
|
|
56
|
+
db.prepare('INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
|
57
|
+
cache = null; // invalidate
|
|
58
|
+
cacheVersion++;
|
|
59
|
+
}
|
|
60
|
+
/** Batch set multiple config values */
|
|
61
|
+
export function setConfigs(entries) {
|
|
62
|
+
const db = getDb();
|
|
63
|
+
initConfigTable();
|
|
64
|
+
const upsert = db.prepare('INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
|
65
|
+
db.transaction(() => {
|
|
66
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
67
|
+
if (CONFIG_KEYS.includes(key)) {
|
|
68
|
+
upsert.run(key, value ?? '');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
cache = null; // invalidate
|
|
73
|
+
cacheVersion++;
|
|
74
|
+
}
|
|
75
|
+
/** Current cache version, can be used for hot-reload detection */
|
|
76
|
+
export function getConfigVersion() {
|
|
77
|
+
return cacheVersion;
|
|
78
|
+
}
|
|
79
|
+
/** Valid config keys list */
|
|
80
|
+
export function getConfigKeys() {
|
|
81
|
+
return CONFIG_KEYS;
|
|
82
|
+
}
|
|
83
|
+
function loadCache() {
|
|
84
|
+
if (cache)
|
|
85
|
+
return;
|
|
86
|
+
initConfigTable();
|
|
87
|
+
const db = getDb();
|
|
88
|
+
const rows = db.prepare('SELECT key, value FROM config').all();
|
|
89
|
+
cache = new Map(rows.map((r) => [r.key, r.value]));
|
|
90
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,14 +19,17 @@ import { withRetry } from './retry.js';
|
|
|
19
19
|
import { createTranscriber, transcribeVideoPosts } from './transcribe.js';
|
|
20
20
|
import { recordPredictions, updateTracking } from './tracker.js';
|
|
21
21
|
import { getDb } from './db.js';
|
|
22
|
+
import { getConfig, initConfigTable } from './config-store.js';
|
|
23
|
+
import { startWebServer } from './web.js';
|
|
22
24
|
// ── Config ──────────────────────────────────────────────────
|
|
23
25
|
const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';
|
|
24
26
|
const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data');
|
|
25
27
|
const isCronMode = process.argv.includes('--cron');
|
|
28
|
+
/** Read config: DB > env > default (getConfig handles all three) */
|
|
26
29
|
function env(key, fallback) {
|
|
27
|
-
const val =
|
|
30
|
+
const val = getConfig(key) || fallback;
|
|
28
31
|
if (!val)
|
|
29
|
-
throw new Error(`Missing
|
|
32
|
+
throw new Error(`Missing config: ${key}`);
|
|
30
33
|
return val;
|
|
31
34
|
}
|
|
32
35
|
function fromFacebook(p) {
|
|
@@ -64,11 +67,11 @@ async function runInner(opts) {
|
|
|
64
67
|
console.log(`\n=== 巴逆逆反指標追蹤器 [${opts.label}] ${now} ===\n`);
|
|
65
68
|
const apifyToken = env('APIFY_TOKEN');
|
|
66
69
|
// 啟動預檢:提前警告設定問題,避免跑完抓取才炸
|
|
67
|
-
if (!opts.isDryRun && !
|
|
70
|
+
if (!opts.isDryRun && !getConfig('LLM_API_KEY')) {
|
|
68
71
|
console.warn('⚠ LLM_API_KEY 未設定,AI 分析將會失敗(可用 --dry 跳過分析)');
|
|
69
72
|
}
|
|
70
|
-
const transcriberType = (
|
|
71
|
-
if (transcriberType === 'groq' && !
|
|
73
|
+
const transcriberType = (getConfig('TRANSCRIBER') || 'noop');
|
|
74
|
+
if (transcriberType === 'groq' && !getConfig('GROQ_API_KEY')) {
|
|
72
75
|
console.warn('⚠ TRANSCRIBER=groq 但 GROQ_API_KEY 未設定,影片轉錄將會失敗');
|
|
73
76
|
}
|
|
74
77
|
const allPosts = [];
|
|
@@ -280,7 +283,11 @@ async function runInner(opts) {
|
|
|
280
283
|
console.log(`結果已存檔: ${outFile}`);
|
|
281
284
|
}
|
|
282
285
|
// ── 入口 ────────────────────────────────────────────────────
|
|
286
|
+
// 初始化 config table(確保 DB schema ready)
|
|
287
|
+
initConfigTable();
|
|
283
288
|
if (isCronMode) {
|
|
289
|
+
// 啟動 Web 設定頁面
|
|
290
|
+
startWebServer();
|
|
284
291
|
// 早晨補漏:每天 08:00
|
|
285
292
|
cron.schedule('0 8 * * *', () => {
|
|
286
293
|
run({ maxPosts: 3, isDryRun: false, label: '早晨' })
|
|
@@ -2,8 +2,8 @@ import type { Notifier } from './types.js';
|
|
|
2
2
|
export type { Notifier, ReportData, PostSummary, AnalysisResult } from './types.js';
|
|
3
3
|
export { sendTelegramDirect } from './telegram.js';
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* 讀取設定,建立所有已設定的 notifier。
|
|
6
|
+
* 每次呼叫都重新讀取(支援熱重載)。
|
|
7
7
|
*/
|
|
8
8
|
export declare function createNotifiers(): Notifier[];
|
|
9
9
|
/**
|
package/dist/notifiers/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { createTelegramNotifier } from './telegram.js';
|
|
2
2
|
import { createDiscordNotifier } from './discord.js';
|
|
3
3
|
import { createLineNotifier } from './line.js';
|
|
4
|
+
import { getConfig } from '../config-store.js';
|
|
4
5
|
export { sendTelegramDirect } from './telegram.js';
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* 讀取設定,建立所有已設定的 notifier。
|
|
8
|
+
* 每次呼叫都重新讀取(支援熱重載)。
|
|
8
9
|
*/
|
|
9
10
|
export function createNotifiers() {
|
|
10
11
|
const notifiers = [];
|
|
11
12
|
// Telegram
|
|
12
|
-
const tgToken =
|
|
13
|
-
const tgChannelId =
|
|
13
|
+
const tgToken = getConfig('TG_BOT_TOKEN');
|
|
14
|
+
const tgChannelId = getConfig('TG_CHANNEL_ID');
|
|
14
15
|
if (tgToken && tgChannelId) {
|
|
15
16
|
notifiers.push(createTelegramNotifier({ botToken: tgToken, channelId: tgChannelId }));
|
|
16
17
|
}
|
|
@@ -18,8 +19,8 @@ export function createNotifiers() {
|
|
|
18
19
|
console.warn(`⚠ Telegram 設定不完整:${tgToken ? '缺少 TG_CHANNEL_ID' : '缺少 TG_BOT_TOKEN'},通知不會啟用`);
|
|
19
20
|
}
|
|
20
21
|
// Discord
|
|
21
|
-
const dcToken =
|
|
22
|
-
const dcChannelId =
|
|
22
|
+
const dcToken = getConfig('DISCORD_BOT_TOKEN');
|
|
23
|
+
const dcChannelId = getConfig('DISCORD_CHANNEL_ID');
|
|
23
24
|
if (dcToken && dcChannelId) {
|
|
24
25
|
notifiers.push(createDiscordNotifier({ botToken: dcToken, channelId: dcChannelId }));
|
|
25
26
|
}
|
|
@@ -27,8 +28,8 @@ export function createNotifiers() {
|
|
|
27
28
|
console.warn(`⚠ Discord 設定不完整:${dcToken ? '缺少 DISCORD_CHANNEL_ID' : '缺少 DISCORD_BOT_TOKEN'},通知不會啟用`);
|
|
28
29
|
}
|
|
29
30
|
// LINE
|
|
30
|
-
const lineToken =
|
|
31
|
-
const lineTo =
|
|
31
|
+
const lineToken = getConfig('LINE_CHANNEL_ACCESS_TOKEN');
|
|
32
|
+
const lineTo = getConfig('LINE_TO');
|
|
32
33
|
if (lineToken && lineTo) {
|
|
33
34
|
notifiers.push(createLineNotifier({ channelAccessToken: lineToken, to: lineTo }));
|
|
34
35
|
}
|
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startWebServer(): void;
|