@cablate/banini-tracker 2.0.19 → 2.0.20

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,10 +4,13 @@
4
4
 
5
5
  # banini-tracker
6
6
 
7
- 追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、多平台即時推送(Telegram / Discord / LINE),並自動追蹤預測準確度。
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
- > **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。
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
- ```bash
43
- # 1. 複製設定
44
- cp .env.example .env
45
- # 填入必要項目,並設定至少一個通知管道(Telegram / Discord / LINE)
43
+ [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/banini-tracker)
46
44
 
47
- # 2. Docker 部署
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
- # 3. 或本地直接跑
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 | 抓前一晚 22:00 後的貼文(3 篇) |
60
- | 盤中 | 週一~五 09:07-13:07 每 30 分 | 抓 08:30 後的貼文(1 篇) |
61
- | 追蹤更新 | 週一~五 15:00 | 更新預測追蹤(收盤後抓 OHLC) |
62
- | 盤後 | 每天 23:03 | 抓 13:30 後的貼文(3 篇) |
76
+ | 早晨補漏 | 每天 08:00 | 抓前一晚的貼文(3 篇) |
77
+ | 盤中 | 週一~五 09:07-13:07 每 30 分 | 即時追蹤(1 篇) |
78
+ | 追蹤更新 | 週一~五 15:00 | 收盤後更新預測追蹤 |
79
+ | 盤後 | 每天 23:03 | 當日彙整(3 篇) |
63
80
 
64
- 每個排程只抓自己時間窗口內的貼文,搭配 seen.json 去重,確保無死角且不重複。
81
+ ## 設定
65
82
 
66
- ### npm scripts
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
- ### .env 設定
87
+ ### 環境變數(.env
77
88
 
78
- ```
79
- APIFY_TOKEN=apify_api_...
80
- LLM_BASE_URL=https://api.deepinfra.com/v1/openai
81
- LLM_API_KEY=...
82
- LLM_MODEL=MiniMaxAI/MiniMax-M2.5
83
- TG_BOT_TOKEN=...
84
- TG_CHANNEL_ID=-100...
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
-
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) 提供去識別化的預測資料,包含 345 筆預測記錄與對應的價格快照,不含原始貼文內容。可直接用於分析或驗證反指標勝率。
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 init \
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
- # 抓取 Facebook 最新 3 篇
149
- npx @cablate/banini-tracker fetch -s fb -n 3 --mark-seen
131
+ # 初始化 CLI 設定
132
+ npx @cablate/banini-tracker init --apify-token YOUR_TOKEN
150
133
 
151
- # 抓取指定日期區間(回測用)
152
- npx @cablate/banini-tracker fetch --since 2025-04-01 --until 2025-05-01 -n 100
134
+ # 抓取貼文
135
+ npx @cablate/banini-tracker fetch -n 3 --mark-seen
153
136
 
154
- # 推送結果到 Telegram
137
+ # 推送到 Telegram
155
138
  npx @cablate/banini-tracker push -f report.txt
156
139
  ```
157
140
 
158
- ### CLI 指令
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
- 詳見 [`skill/SKILL.md`](skill/SKILL.md)。
143
+ 完整指令說明見 `npx @cablate/banini-tracker --help`。
200
144
 
201
145
  ## 費用估算
202
146
 
203
- | 項目 | 單次費用 | 頻率 | 月估算 |
204
- |------|---------|------|--------|
205
- | Facebook 抓取(Apify) | ~$0.005/篇 | ~270 篇/月 | ~$1.35 |
206
- | LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 |
207
- | 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 |
208
- | 股價查詢(FinMind) | 免費 | 每日收盤後 | $0 |
209
- | 通知推送(TG / DC) | 免費 | — | $0 |
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
- 1. **費用差距大**:Threads 每次抓取 ~$0.15(Pay-per-event),Facebook 只要 ~$0.02(CU 計費),差 7 倍以上
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
- MIT
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 = process.env[key] ?? fallback;
30
+ const val = getConfig(key) || fallback;
28
31
  if (!val)
29
- throw new Error(`Missing env: ${key}`);
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 && !process.env.LLM_API_KEY) {
70
+ if (!opts.isDryRun && !getConfig('LLM_API_KEY')) {
68
71
  console.warn('⚠ LLM_API_KEY 未設定,AI 分析將會失敗(可用 --dry 跳過分析)');
69
72
  }
70
- const transcriberType = (process.env.TRANSCRIBER ?? 'noop');
71
- if (transcriberType === 'groq' && !process.env.GROQ_API_KEY) {
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
- * 讀取環境變數,建立所有已設定的 notifier。
6
- * 常駐模式用:自動偵測哪些 channel 有設定。
5
+ * 讀取設定,建立所有已設定的 notifier。
6
+ * 每次呼叫都重新讀取(支援熱重載)。
7
7
  */
8
8
  export declare function createNotifiers(): Notifier[];
9
9
  /**
@@ -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
- * 讀取環境變數,建立所有已設定的 notifier。
7
- * 常駐模式用:自動偵測哪些 channel 有設定。
7
+ * 讀取設定,建立所有已設定的 notifier。
8
+ * 每次呼叫都重新讀取(支援熱重載)。
8
9
  */
9
10
  export function createNotifiers() {
10
11
  const notifiers = [];
11
12
  // Telegram
12
- const tgToken = process.env.TG_BOT_TOKEN;
13
- const tgChannelId = process.env.TG_CHANNEL_ID;
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 = process.env.DISCORD_BOT_TOKEN;
22
- const dcChannelId = process.env.DISCORD_CHANNEL_ID;
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 = process.env.LINE_CHANNEL_ACCESS_TOKEN;
31
- const lineTo = process.env.LINE_TO;
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;