From 1508fa24a4627e4acd38c33e1113883eaf264565 Mon Sep 17 00:00:00 2001 From: CabLate Date: Sun, 12 Apr 2026 16:19:29 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Web=20=E8=A8=AD=E5=AE=9A=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=20+=20config=20store=20=E7=86=B1=E9=87=8D=E8=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Web UI 設定頁面(port 3000),首次進入建立管理員帳密 - DB-backed config store(DB > env > default 三層 fallback) - scrypt 密碼雜湊 + httpOnly/sameSite/secure cookie - 設定修改後下次排程自動生效,不需重啟容器 - Zeabur template 簡化為零 env 部署(全部走 Web UI) - README 重寫:Zeabur 一鍵部署為主要推薦方式 - PUT /api/config 輸入驗證 + ConfigKey type safety - 前端 masking 改為比對 originalValues(支援清除已設定的值) Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 + README.md | 205 +++++++++-------------------- package-lock.json | 23 ++++ package.json | 2 + src/auth.ts | 85 ++++++++++++ src/config-store.ts | 111 ++++++++++++++++ src/index.ts | 20 ++- src/notifiers/index.ts | 17 +-- src/web.ts | 284 +++++++++++++++++++++++++++++++++++++++++ zeabur-template.yaml | 56 +++----- 10 files changed, 612 insertions(+), 193 deletions(-) create mode 100644 src/auth.ts create mode 100644 src/config-store.ts create mode 100644 src/web.ts diff --git a/Dockerfile b/Dockerfile index 5e78273..1afea3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,6 @@ RUN npm run build ENV NODE_ENV=production ENV DATA_DIR=/data +EXPOSE 3000 + CMD ["node", "dist/index.js", "--cron"] diff --git a/README.md b/README.md index b81acda..1605f4e 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,9 @@ # banini-tracker -追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、多平台即時推送(Telegram / Discord / LINE),並自動追蹤預測準確度。 +追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 AI 反指標分析、多平台即時推送(Telegram / Discord / LINE),並自動追蹤預測準確度。 - 辨識她提到的標的(個股、ETF、原物料) -- 判斷她的操作(買入 / 被套 / 停損) - 反轉推導(她停損 → 可能反彈、她買入 → 可能下跌) - 推導連鎖效應(油價跌 → 製造業利多 → 電子股受惠) - 自動記錄預測,追蹤 5 個交易日的實際走勢 @@ -31,78 +30,67 @@ --- -> **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。 +## 快速開始 -支援兩種使用模式: -- **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + 多平台推送(Telegram / Discord / LINE) + 預測追蹤 -- **CLI 工具**:`npx @cablate/banini-tracker`,搭配 Claude Code 等 AI 手動執行分析 +三種部署方式,按推薦順序: -## 快速開始(常駐排程) +### 1. Zeabur 一鍵部署(推薦) -```bash -# 1. 複製設定 -cp .env.example .env -# 填入必要項目,並設定至少一個通知管道(Telegram / Discord / LINE) +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/banini-tracker) + +部署後打開分配的網域,首次進入設定管理員帳密,然後在 Web UI 填入 API key 和通知管道。不需要手動設定環境變數。 -# 2. Docker 部署 +### 2. Docker + +```bash docker build -t banini-tracker . -docker run -d --name banini --env-file .env -v banini-data:/data banini-tracker +docker run -d --name banini --env-file .env -v banini-data:/data -p 3000:3000 banini-tracker +``` + +部署後打開 `http://localhost:3000` 進入設定頁面。也可以直接用 `.env` 檔設定環境變數(見下方)。 + +### 3. 本地開發 -# 3. 或本地直接跑 +```bash +cp .env.example .env # 填入必要設定 npm install && npm run start ``` -### 排程規則 +## 排程規則 | 排程 | 時間 | 說明 | |------|------|------| -| 早晨補漏 | 每天 08:00 | 抓前一晚 22:00 後的貼文(3 篇) | -| 盤中 | 週一~五 09:07-13:07 每 30 分 | 抓 08:30 後的貼文(1 篇) | -| 追蹤更新 | 週一~五 15:00 | 更新預測追蹤(收盤後抓 OHLC) | -| 盤後 | 每天 23:03 | 抓 13:30 後的貼文(3 篇) | +| 早晨補漏 | 每天 08:00 | 抓前一晚的貼文(3 篇) | +| 盤中 | 週一~五 09:07-13:07 每 30 分 | 即時追蹤(1 篇) | +| 追蹤更新 | 週一~五 15:00 | 收盤後更新預測追蹤 | +| 盤後 | 每天 23:03 | 當日彙整(3 篇) | -每個排程只抓自己時間窗口內的貼文,搭配 seen.json 去重,確保無死角且不重複。 +## 設定 -### npm scripts +### Web UI(Zeabur / Docker) -| 指令 | 說明 | -|------|------| -| `npm run start` | 常駐排程模式(全部排程自動跑) | -| `npm run dev` | 單次執行(FB 3 篇) | -| `npm run dry` | 只抓取,不呼叫 LLM | -| `npm run market` | 盤中模式(FB 1 篇) | -| `npm run evening` | 盤後模式(FB 3 篇) | +常駐模式會在 port 3000 啟動設定頁面。首次進入建立管理員帳密,之後登入即可修改設定。修改後下次排程執行自動生效,不需重啟。 -### .env 設定 +### 環境變數(.env) -``` -APIFY_TOKEN=apify_api_... -LLM_BASE_URL=https://api.deepinfra.com/v1/openai -LLM_API_KEY=... -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_... - -# FinMind API(選填,免費可用,註冊可提高額度) -FINMIND_TOKEN=... - -# 資料目錄(Docker 建議掛載 /data) -DATA_DIR=/data -``` +如果不使用 Web UI,也可以直接設定環境變數: + +| 變數 | 必填 | 說明 | +|------|------|------| +| `APIFY_TOKEN` | 是 | Apify API token | +| `LLM_API_KEY` | 是 | LLM API key | +| `LLM_BASE_URL` | — | 預設 DeepInfra | +| `LLM_MODEL` | — | 預設 MiniMax-M2.5 | +| `TG_BOT_TOKEN` + `TG_CHANNEL_ID` | 至少一組 | Telegram 通知 | +| `DISCORD_BOT_TOKEN` + `DISCORD_CHANNEL_ID` | 至少一組 | Discord 通知 | +| `LINE_CHANNEL_ACCESS_TOKEN` + `LINE_TO` | 至少一組 | LINE 通知(Free plan 200 則/月) | +| `TRANSCRIBER` | — | `noop`(預設)或 `groq` | +| `GROQ_API_KEY` | — | Groq Whisper 影片轉錄用 | +| `FINMIND_TOKEN` | — | 股價查詢(免費可用) | -## 預測追蹤系統 +Web UI 和環境變數可以混用,Web UI 的設定優先。 + +## 預測追蹤 LLM 分析出標的後,系統自動: @@ -111,113 +99,44 @@ LLM 分析出標的後,系統自動: 3. **追蹤 5 個交易日**:每天 15:00 收盤後抓 OHLC,記錄漲跌幅 4. **同股票取代**:新預測自動取代同標的舊預測(supersede 機制) -勝敗判定在查詢時決定,支援多維度分析(不同持有天數、信心度分群、操作類型)。 - -### 資料儲存 - -使用 SQLite(better-sqlite3),資料表: - -| 表 | 用途 | -|----|------| -| `posts` | 所有貼文原文(即時 + 歷史回測統一來源) | -| `predictions` | 預測記錄(標的、方向、基準價、狀態) | -| `price_snapshots` | 每日 OHLC 快照(5 天追蹤期) | - -資料庫位置:`$DATA_DIR/banini.db`(Docker 掛載 `/data`,本地 `~/.banini-tracker/`) - ### 公開資料集 -[`data/banini-public.db`](data/banini-public.db) 提供去識別化的預測資料,包含 345 筆預測記錄與對應的價格快照,不含原始貼文內容。可直接用於分析或驗證反指標勝率。 +[`data/banini-public.db`](data/banini-public.db) 提供去識別化的預測資料(345 筆預測 + 價格快照),不含原始貼文。 ```bash -# 快速查看 sqlite3 data/banini-public.db "SELECT symbol_name, reverse_view, base_price, status FROM predictions LIMIT 10" ``` -## CLI 工具模式 +## CLI 工具 -不需 clone repo,任何環境直接用: +不需 clone repo,搭配 Claude Code 等 AI 使用: ```bash -# 初始化設定 -npx @cablate/banini-tracker init \ - --apify-token YOUR_APIFY_TOKEN \ - --tg-bot-token YOUR_TG_BOT_TOKEN \ - --tg-channel-id YOUR_TG_CHANNEL_ID +# 初始化 +npx @cablate/banini-tracker init --apify-token YOUR_TOKEN -# 抓取 Facebook 最新 3 篇 -npx @cablate/banini-tracker fetch -s fb -n 3 --mark-seen +# 抓取 +npx @cablate/banini-tracker fetch -n 3 --mark-seen -# 抓取指定日期區間(回測用) -npx @cablate/banini-tracker fetch --since 2025-04-01 --until 2025-05-01 -n 100 - -# 推送結果到 Telegram +# 推送到 Telegram npx @cablate/banini-tracker push -f report.txt ``` -### CLI 指令 - -| 指令 | 說明 | -|------|------| -| `init` | 初始化設定檔(`~/.banini-tracker.json`) | -| `config` | 顯示目前設定 | -| `fetch` | 抓取貼文,輸出 JSON 到 stdout | -| `push` | 推送訊息到 Telegram | -| `seen list` | 列出已讀貼文 ID | -| `seen mark ` | 標記貼文為已讀 | -| `seen clear` | 清空已讀紀錄 | - -### fetch 選項 - -``` --s, --source 來源:fb(預設 fb) --n, --limit 每個來源抓幾篇(預設 3) ---since 只抓此時間之後的貼文(YYYY-MM-DD / ISO 時間戳 / 相對時間如 "2 months") ---until 只抓此時間之前的貼文 ---no-dedup 不去重 ---mark-seen 輸出後自動標記已讀 -``` - -### push 選項 - -``` --m, --message 直接帶訊息 --f, --file 從檔案讀取(推薦多行內容用這個) ---parse-mode HTML / Markdown / none(預設 HTML) -``` - -不帶 `-m` 或 `-f` 時從 stdin 讀取。 - -### 搭配 Claude Code 使用 - -在 Claude Code 的 skill 中,Claude 自己就是分析引擎: - -1. `fetch` 抓貼文 → Claude 讀 JSON -2. Claude 分析 + WebSearch 查最新走勢 -3. Claude 組報告 → `push -f` 推送 Telegram +> **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。 -詳見 [`skill/SKILL.md`](skill/SKILL.md)。 +完整指令說明見 `npx @cablate/banini-tracker --help`。 ## 費用估算 -| 項目 | 單次費用 | 頻率 | 月估算 | -|------|---------|------|--------| -| Facebook 抓取(Apify) | ~$0.005/篇 | ~270 篇/月 | ~$1.35 | -| LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 | -| 影片轉錄(Groq Whisper) | ~$0.006/分鐘 | 視影片數量 | 極低 | -| 股價查詢(FinMind) | 免費 | 每日收盤後 | $0 | -| 通知推送(TG / DC) | 免費 | — | $0 | -| LINE 推送 | Free plan 200 則/月 | 同上 | $0(一般用量) | - -> CLI 模式搭配 Claude Code 使用不需 LLM 費用,Claude 自己分析。 -> 回測歷史資料加日期篩選:~$7/千篇($5 基本 + $2 date filter add-on)。 - -## 為什麼只用 Facebook? - -早期版本同時支援 Threads 和 Facebook 爬取,後來基於兩個原因移除了 Threads: +| 項目 | 月估算 | +|------|--------| +| Facebook 抓取(Apify) | ~$1.35(~270 篇) | +| LLM 分析 | 依模型定價 | +| 通知推送(TG / DC) | 免費 | +| LINE 推送 | Free plan 200 則/月 | +| 股價查詢(FinMind) | 免費 | -1. **費用差距大**:Threads 每次抓取 ~$0.15(Pay-per-event),Facebook 只要 ~$0.02(CU 計費),差 7 倍以上 -2. **FB 參考價值更高**:巴逆逆的投資相關貼文(持倉截圖、操作心得)主要發在 Facebook 粉專,Threads 多為生活日常,反指標參考價值較低 +> CLI 模式搭配 Claude Code 不需 LLM 費用,Claude 自己分析。 ## Star History diff --git a/package-lock.json b/package-lock.json index d73b088..d0ccb00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "2.0.19", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.13", "better-sqlite3": "^12.8.0", "commander": "^13.0.0", "dotenv": "^16.4.0", "groq-sdk": "^1.1.2", + "hono": "^4.12.12", "node-cron": "^4.2.1", "openai": "^4.0.0" }, @@ -469,6 +471,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1022,6 +1036,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", diff --git a/package.json b/package.json index 9a482c3..e808126 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,12 @@ "author": "cablate", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.13", "better-sqlite3": "^12.8.0", "commander": "^13.0.0", "dotenv": "^16.4.0", "groq-sdk": "^1.1.2", + "hono": "^4.12.12", "node-cron": "^4.2.1", "openai": "^4.0.0" }, diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..8d7f0bd --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,85 @@ +/** + * Auth module: first-run setup + login + session token. + * Password hashed with scrypt, session token in memory. + */ +import { scryptSync, randomBytes, timingSafeEqual } from 'crypto'; +import { getDb } from './db.js'; + +const SCRYPT_KEYLEN = 64; + +/** Ensure auth table exists */ +export function initAuthTable(): void { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS auth ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ) + `); +} + +/** Check if initial setup is done */ +export function isInitialized(): boolean { + initAuthTable(); + const db = getDb(); + const row = db.prepare('SELECT id FROM auth WHERE id = 1').get(); + return !!row; +} + +/** First-run: create admin account */ +export function setupAdmin(username: string, password: string): void { + if (isInitialized()) throw new Error('already initialized'); + if (!username || !password) throw new Error('username and password required'); + if (password.length < 6) throw new Error('password must be at least 6 characters'); + + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex'); + const stored = `${salt}:${hash}`; + + const db = getDb(); + db.prepare('INSERT INTO auth (id, username, password_hash) VALUES (1, ?, ?)').run(username, stored); +} + +/** Verify credentials, return session token or null */ +export function login(username: string, password: string): string | null { + initAuthTable(); + const db = getDb(); + const row = db.prepare('SELECT username, password_hash FROM auth WHERE id = 1').get() as + | { username: string; password_hash: string } + | undefined; + + if (!row) return null; + if (row.username !== username) return null; + + const [salt, storedHash] = row.password_hash.split(':'); + const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex'); + + const storedBuf = Buffer.from(storedHash, 'hex'); + const hashBuf = Buffer.from(hash, 'hex'); + if (!timingSafeEqual(storedBuf, hashBuf)) return null; + + const token = randomBytes(32).toString('hex'); + sessions.set(token, { username, createdAt: Date.now() }); + return token; +} + +/** Validate session token */ +export function validateSession(token: string): boolean { + const session = sessions.get(token); + if (!session) return false; + // 24h expiry + if (Date.now() - session.createdAt > 24 * 60 * 60 * 1000) { + sessions.delete(token); + return false; + } + return true; +} + +/** Logout */ +export function logout(token: string): void { + sessions.delete(token); +} + +// In-memory session store (container restart = re-login, acceptable) +const sessions = new Map(); diff --git a/src/config-store.ts b/src/config-store.ts new file mode 100644 index 0000000..34b7ecd --- /dev/null +++ b/src/config-store.ts @@ -0,0 +1,111 @@ +/** + * DB-backed config store with env fallback + in-memory cache. + * Web UI writes here, cron reads here on every run(). + */ +import { getDb } from './db.js'; + +export interface ConfigEntry { + key: string; + value: string; +} + +const CONFIG_KEYS = [ + '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', +] as const; + +export type ConfigKey = typeof CONFIG_KEYS[number]; + +const DEFAULTS: Partial> = { + LLM_BASE_URL: 'https://api.deepinfra.com/v1/openai', + LLM_MODEL: 'MiniMaxAI/MiniMax-M2.5', + TRANSCRIBER: 'noop', +}; + +let cache: Map | null = null; +let cacheVersion = 0; + +/** Ensure config table exists */ +export function initConfigTable(): void { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ) + `); +} + +/** Get a single config value: DB > env > default */ +export function getConfig(key: ConfigKey): string { + loadCache(); + return cache!.get(key) || process.env[key] || DEFAULTS[key] || ''; +} + +/** Get all config as object */ +export function getAllConfig(): Record { + loadCache(); + const result = {} as Record; + for (const key of CONFIG_KEYS) { + result[key] = cache!.get(key) || process.env[key] || DEFAULTS[key] || ''; + } + return result; +} + +/** Set a single config value in DB */ +export function setConfig(key: ConfigKey, value: string): void { + const db = getDb(); + initConfigTable(); + db.prepare( + 'INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ).run(key, value); + cache = null; // invalidate + cacheVersion++; +} + +/** Batch set multiple config values */ +export function setConfigs(entries: Partial>): void { + const db = getDb(); + initConfigTable(); + const upsert = db.prepare( + 'INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ); + db.transaction(() => { + for (const [key, value] of Object.entries(entries)) { + if (CONFIG_KEYS.includes(key as ConfigKey)) { + upsert.run(key, value ?? ''); + } + } + })(); + cache = null; // invalidate + cacheVersion++; +} + +/** Current cache version, can be used for hot-reload detection */ +export function getConfigVersion(): number { + return cacheVersion; +} + +/** Valid config keys list */ +export function getConfigKeys(): readonly string[] { + return CONFIG_KEYS; +} + +function loadCache(): void { + if (cache) return; + initConfigTable(); + const db = getDb(); + const rows = db.prepare('SELECT key, value FROM config').all() as ConfigEntry[]; + cache = new Map(rows.map((r) => [r.key, r.value])); +} diff --git a/src/index.ts b/src/index.ts index 293c31e..e16256f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ import { withRetry } from './retry.js'; import { createTranscriber, transcribeVideoPosts, type TranscriberType } from './transcribe.js'; import { recordPredictions, updateTracking } from './tracker.js'; import { getDb } from './db.js'; +import { getConfig, initConfigTable, type ConfigKey } from './config-store.js'; +import { startWebServer } from './web.js'; // ── Config ────────────────────────────────────────────────── const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/'; @@ -26,9 +28,10 @@ const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data'); const isCronMode = process.argv.includes('--cron'); -function env(key: string, fallback?: string): string { - const val = process.env[key] ?? fallback; - if (!val) throw new Error(`Missing env: ${key}`); +/** Read config: DB > env > default (getConfig handles all three) */ +function env(key: ConfigKey, fallback?: string): string { + const val = getConfig(key) || fallback; + if (!val) throw new Error(`Missing config: ${key}`); return val; } @@ -95,11 +98,11 @@ async function runInner(opts: RunOptions) { const apifyToken = env('APIFY_TOKEN'); // 啟動預檢:提前警告設定問題,避免跑完抓取才炸 - if (!opts.isDryRun && !process.env.LLM_API_KEY) { + if (!opts.isDryRun && !getConfig('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) { + const transcriberType = (getConfig('TRANSCRIBER') || 'noop') as TranscriberType; + if (transcriberType === 'groq' && !getConfig('GROQ_API_KEY')) { console.warn('⚠ TRANSCRIBER=groq 但 GROQ_API_KEY 未設定,影片轉錄將會失敗'); } @@ -328,7 +331,12 @@ async function runInner(opts: RunOptions) { } // ── 入口 ──────────────────────────────────────────────────── +// 初始化 config table(確保 DB schema ready) +initConfigTable(); + if (isCronMode) { + // 啟動 Web 設定頁面 + startWebServer(); // 早晨補漏:每天 08:00 cron.schedule('0 8 * * *', () => { run({ maxPosts: 3, isDryRun: false, label: '早晨' }) diff --git a/src/notifiers/index.ts b/src/notifiers/index.ts index de36fe4..a5fe087 100644 --- a/src/notifiers/index.ts +++ b/src/notifiers/index.ts @@ -2,20 +2,21 @@ import { createTelegramNotifier } from './telegram.js'; import { createDiscordNotifier } from './discord.js'; import { createLineNotifier } from './line.js'; import type { Notifier } from './types.js'; +import { getConfig } from '../config-store.js'; export type { Notifier, ReportData, PostSummary, AnalysisResult } from './types.js'; export { sendTelegramDirect } from './telegram.js'; /** - * 讀取環境變數,建立所有已設定的 notifier。 - * 常駐模式用:自動偵測哪些 channel 有設定。 + * 讀取設定,建立所有已設定的 notifier。 + * 每次呼叫都重新讀取(支援熱重載)。 */ export function createNotifiers(): Notifier[] { const notifiers: Notifier[] = []; // Telegram - const tgToken = process.env.TG_BOT_TOKEN; - const tgChannelId = process.env.TG_CHANNEL_ID; + const tgToken = getConfig('TG_BOT_TOKEN'); + const tgChannelId = getConfig('TG_CHANNEL_ID'); if (tgToken && tgChannelId) { notifiers.push(createTelegramNotifier({ botToken: tgToken, channelId: tgChannelId })); } else if (tgToken || tgChannelId) { @@ -23,8 +24,8 @@ export function createNotifiers(): Notifier[] { } // Discord - const dcToken = process.env.DISCORD_BOT_TOKEN; - const dcChannelId = process.env.DISCORD_CHANNEL_ID; + const dcToken = getConfig('DISCORD_BOT_TOKEN'); + const dcChannelId = getConfig('DISCORD_CHANNEL_ID'); if (dcToken && dcChannelId) { notifiers.push(createDiscordNotifier({ botToken: dcToken, channelId: dcChannelId })); } else if (dcToken || dcChannelId) { @@ -32,8 +33,8 @@ export function createNotifiers(): Notifier[] { } // LINE - const lineToken = process.env.LINE_CHANNEL_ACCESS_TOKEN; - const lineTo = process.env.LINE_TO; + const lineToken = getConfig('LINE_CHANNEL_ACCESS_TOKEN'); + const lineTo = getConfig('LINE_TO'); if (lineToken && lineTo) { notifiers.push(createLineNotifier({ channelAccessToken: lineToken, to: lineTo })); } else if (lineToken || lineTo) { diff --git a/src/web.ts b/src/web.ts new file mode 100644 index 0000000..16a31e9 --- /dev/null +++ b/src/web.ts @@ -0,0 +1,284 @@ +/** + * Web config panel: Hono server with auth + config CRUD. + * Single HTML page, no build step. + */ +import { Hono } from 'hono'; +import type { MiddlewareHandler } from 'hono'; +import { serve } from '@hono/node-server'; +import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; +import { isInitialized, setupAdmin, login, logout, validateSession } from './auth.js'; +import { getAllConfig, setConfigs, getConfigKeys, type ConfigKey } from './config-store.js'; + +const app = new Hono(); + +const isProduction = process.env.NODE_ENV === 'production'; + +// ── Auth middleware ── +const requireAuth: MiddlewareHandler = async (c, next) => { + const token = getCookie(c, 'session'); + if (!token || !validateSession(token)) { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); +}; + +// ── API routes ── + +// Check init status +app.get('/api/status', (c) => { + return c.json({ initialized: isInitialized() }); +}); + +// First-run setup +app.post('/api/setup', async (c) => { + if (isInitialized()) return c.json({ error: 'already initialized' }, 400); + const { username, password } = await c.req.json(); + try { + setupAdmin(username, password); + const token = login(username, password); + if (token) setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' }); + return c.json({ ok: true }); + } catch (err: any) { + return c.json({ error: err.message }, 400); + } +}); + +// Login +app.post('/api/login', async (c) => { + const { username, password } = await c.req.json(); + const token = login(username, password); + if (!token) return c.json({ error: 'invalid credentials' }, 401); + setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' }); + return c.json({ ok: true }); +}); + +// Logout +app.post('/api/logout', (c) => { + const token = getCookie(c, 'session'); + if (token) logout(token); + deleteCookie(c, 'session', { path: '/' }); + return c.json({ ok: true }); +}); + +// Get config (auth required) +app.get('/api/config', requireAuth, (c) => { + const config = getAllConfig(); + // Mask sensitive values for display + const masked: Record = {}; + for (const [key, value] of Object.entries(config)) { + if (value && (key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'))) { + masked[key] = value.slice(0, 6) + '***'; + } else { + masked[key] = value; + } + } + return c.json({ config: masked, keys: getConfigKeys() }); +}); + +// Update config (auth required) +app.put('/api/config', requireAuth, async (c) => { + const body = await c.req.json(); + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return c.json({ error: 'invalid payload' }, 400); + } + const entries: Partial> = {}; + for (const [key, value] of Object.entries(body)) { + if (typeof value !== 'string') { + return c.json({ error: `invalid value for ${key}: expected string` }, 400); + } + entries[key as ConfigKey] = value; + } + setConfigs(entries); + return c.json({ ok: true }); +}); + +// ── Frontend ── +app.get('/', (c) => { + return c.html(FRONTEND_HTML); +}); + +// ── Start server ── +const WEB_PORT = parseInt(process.env.WEB_PORT || '3000', 10); + +export function startWebServer(): void { + serve({ fetch: app.fetch, port: WEB_PORT }, () => { + console.log(`[Web] 設定頁面已啟動: http://localhost:${WEB_PORT}`); + }); +} + +// ── Inline frontend ── +const FRONTEND_HTML = ` + + + + +banini-tracker 設定 + + + +
+

banini-tracker 設定

+ + + + + + +
+ + + +`; diff --git a/zeabur-template.yaml b/zeabur-template.yaml index c017db1..61afd4d 100644 --- a/zeabur-template.yaml +++ b/zeabur-template.yaml @@ -13,18 +13,15 @@ spec: 追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 貼文,透過 AI 反指標分析,自動推送到 Telegram / Discord / LINE。 - ## 必填設定 + ## 部署後設定 - | 變數 | 說明 | - |------|------| - | APIFY_TOKEN | Apify API token(Facebook 抓取用) | - | LLM_API_KEY | LLM API key(AI 分析用) | + 部署完成後,打開網頁設定頁面(Zeabur 會自動分配網域),首次進入需建立管理員帳密,然後在頁面上填入: - 至少設定一個通知管道: + - APIFY_TOKEN(必填) + - LLM_API_KEY(必填) + - 至少一個通知管道(Telegram / Discord / LINE) - - **Telegram**:TG_BOT_TOKEN + TG_CHANNEL_ID - - **Discord**:DISCORD_BOT_TOKEN + DISCORD_CHANNEL_ID - - **LINE**:LINE_CHANNEL_ACCESS_TOKEN + LINE_TO + 其餘設定皆有合理預設值,可按需調整。 ## 排程 @@ -38,47 +35,34 @@ spec: ## 詳細說明 [GitHub README](https://github.com/cablate/banini-tracker) - variables: [] + variables: + - key: PUBLIC_DOMAIN + type: DOMAIN + name: Domain + description: 設定頁面網域 services: - name: banini-tracker icon: https://raw.githubusercontent.com/cablate/banini-tracker/master/assets/banner.svg template: PREBUILT + domainKey: PUBLIC_DOMAIN spec: source: image: ghcr.io/cablate/banini-tracker:latest + ports: + - id: web + port: 3000 + type: HTTP volumes: - id: data dir: /data env: - APIFY_TOKEN: - default: "" - LLM_BASE_URL: - default: "https://api.deepinfra.com/v1/openai" - LLM_API_KEY: - default: "" - LLM_MODEL: - default: "MiniMaxAI/MiniMax-M2.5" - TG_BOT_TOKEN: - default: "" - TG_CHANNEL_ID: - default: "" - DISCORD_BOT_TOKEN: - default: "" - DISCORD_CHANNEL_ID: - default: "" - LINE_CHANNEL_ACCESS_TOKEN: - default: "" - LINE_TO: - default: "" - TRANSCRIBER: - default: "noop" - GROQ_API_KEY: - default: "" - FINMIND_TOKEN: - default: "" DATA_DIR: default: "/data" readonly: true locales: zh-TW: description: 巴逆逆(8zz)反指標追蹤器 — AI 分析 Facebook 貼文 + 多平台推送(Telegram / Discord / LINE) + variables: + - key: PUBLIC_DOMAIN + name: 網域 + description: 設定頁面的網域 From 5cb0428cdcf786d529fb201609e6df167bc0adb7 Mon Sep 17 00:00:00 2001 From: CabLate Date: Sun, 12 Apr 2026 17:02:42 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20CLI=20serve=20?= =?UTF-8?q?=E5=AD=90=E5=91=BD=E4=BB=A4=20+=20=E7=B5=B1=E4=B8=80=20Dockerfi?= =?UTF-8?q?le=20=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `npx @cablate/banini-tracker serve [--port 8080]` 直接跑常駐服務 - Dockerfile CMD 統一為 `node dist/cli.js serve` - 動態 import index.ts 複用現有 cron + web server 邏輯 Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- src/cli.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1afea3e..1d19565 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ ENV DATA_DIR=/data EXPOSE 3000 -CMD ["node", "dist/index.js", "--cron"] +CMD ["node", "dist/cli.js", "serve"] diff --git a/src/cli.ts b/src/cli.ts index dd79468..3986934 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -181,6 +181,17 @@ seenCmd console.error(`已清空: ${getSeenFile()}`); }); +// ── serve ──────────────────────────────────────────────── +program + .command('serve') + .description('啟動常駐服務(排程 + Web 設定頁面)') + .option('-p, --port ', 'Web UI port', '3000') + .action(async (opts) => { + if (opts.port) process.env.WEB_PORT = opts.port; + process.argv.push('--cron'); + await import('./index.js'); + }); + // ── push ───────────────────────────────────────────────── program .command('push') From 8c5ef2e13a78d3487226a7e3b6976b9ef9ee7faa Mon Sep 17 00:00:00 2001 From: CabLate Date: Sun, 12 Apr 2026 17:06:06 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20README=20=E5=8A=A0=E5=85=A5=20npx?= =?UTF-8?q?=20serve=20=E9=83=A8=E7=BD=B2=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1605f4e..a846ecc 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ ## 快速開始 -三種部署方式,按推薦順序: +四種使用方式,按推薦順序: ### 1. Zeabur 一鍵部署(推薦) @@ -49,7 +49,16 @@ docker run -d --name banini --env-file .env -v banini-data:/data -p 3000:3000 ba 部署後打開 `http://localhost:3000` 進入設定頁面。也可以直接用 `.env` 檔設定環境變數(見下方)。 -### 3. 本地開發 +### 3. npx 直接啟動 + +```bash +npx @cablate/banini-tracker serve +npx @cablate/banini-tracker serve --port 8080 +``` + +不需 Docker,直接在本機啟動常駐服務(排程 + Web 設定頁面)。打開 `http://localhost:3000` 進入設定。適合有 Node.js 環境的使用者。 + +### 4. 本地開發 ```bash cp .env.example .env # 填入必要設定 @@ -112,10 +121,13 @@ sqlite3 data/banini-public.db "SELECT symbol_name, reverse_view, base_price, sta 不需 clone repo,搭配 Claude Code 等 AI 使用: ```bash -# 初始化 +# 啟動常駐服務(排程 + Web UI) +npx @cablate/banini-tracker serve + +# 初始化 CLI 設定 npx @cablate/banini-tracker init --apify-token YOUR_TOKEN -# 抓取 +# 抓取貼文 npx @cablate/banini-tracker fetch -n 3 --mark-seen # 推送到 Telegram From 2cfb41c6b49a030574e298c62853e85a1168b83c Mon Sep 17 00:00:00 2001 From: CabLate Date: Sun, 12 Apr 2026 18:16:48 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20Web=20UI=20=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=E6=8C=89=E9=88=95=20+=20SSE=20=E4=B8=B2=E6=B5=81=20+=20?= =?UTF-8?q?=E6=B7=BA=E8=89=B2=E4=B8=BB=E9=A1=8C=E9=87=8D=E8=A8=AD=E8=A8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增測試按鈕:抓取 1 篇貼文 → LLM 分析 → 通知推送,逐步顯示結果 - SSE 串流(hono/streaming)即時回傳每一步進度 - 左右雙欄全頁佈局:左欄設定表單 / 右欄即時 Log 面板 - Log 面板:時間戳 + 顏色區分、複製/清除按鈕 - 套用 taste-skill 淺色主題:Outfit 字型、Slate/Zinc 色階、Emerald 強調色 - 修正字型過小(0.875rem base)、純黑背景(改 #f8fafc)、間距不足 Co-Authored-By: Claude Opus 4.6 --- src/web.ts | 575 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 459 insertions(+), 116 deletions(-) diff --git a/src/web.ts b/src/web.ts index 16a31e9..8c968f3 100644 --- a/src/web.ts +++ b/src/web.ts @@ -4,16 +4,20 @@ */ import { Hono } from 'hono'; import type { MiddlewareHandler } from 'hono'; +import { streamSSE } from 'hono/streaming'; import { serve } from '@hono/node-server'; import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; import { isInitialized, setupAdmin, login, logout, validateSession } from './auth.js'; -import { getAllConfig, setConfigs, getConfigKeys, type ConfigKey } from './config-store.js'; +import { getAllConfig, setConfigs, getConfigKeys, getConfig, type ConfigKey } from './config-store.js'; +import { fetchFacebookPosts } from './facebook.js'; +import { analyzePosts } from './analyze.js'; +import { createNotifiers, type ReportData, type PostSummary } from './notifiers/index.js'; const app = new Hono(); const isProduction = process.env.NODE_ENV === 'production'; -// ── Auth middleware ── +// -- Auth middleware -- const requireAuth: MiddlewareHandler = async (c, next) => { const token = getCookie(c, 'session'); if (!token || !validateSession(token)) { @@ -22,14 +26,12 @@ const requireAuth: MiddlewareHandler = async (c, next) => { await next(); }; -// ── API routes ── +// -- API routes -- -// Check init status app.get('/api/status', (c) => { return c.json({ initialized: isInitialized() }); }); -// First-run setup app.post('/api/setup', async (c) => { if (isInitialized()) return c.json({ error: 'already initialized' }, 400); const { username, password } = await c.req.json(); @@ -43,7 +45,6 @@ app.post('/api/setup', async (c) => { } }); -// Login app.post('/api/login', async (c) => { const { username, password } = await c.req.json(); const token = login(username, password); @@ -52,7 +53,6 @@ app.post('/api/login', async (c) => { return c.json({ ok: true }); }); -// Logout app.post('/api/logout', (c) => { const token = getCookie(c, 'session'); if (token) logout(token); @@ -60,10 +60,8 @@ app.post('/api/logout', (c) => { return c.json({ ok: true }); }); -// Get config (auth required) app.get('/api/config', requireAuth, (c) => { const config = getAllConfig(); - // Mask sensitive values for display const masked: Record = {}; for (const [key, value] of Object.entries(config)) { if (value && (key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'))) { @@ -75,7 +73,6 @@ app.get('/api/config', requireAuth, (c) => { return c.json({ config: masked, keys: getConfigKeys() }); }); -// Update config (auth required) app.put('/api/config', requireAuth, async (c) => { const body = await c.req.json(); if (typeof body !== 'object' || body === null || Array.isArray(body)) { @@ -92,12 +89,95 @@ app.put('/api/config', requireAuth, async (c) => { return c.json({ ok: true }); }); -// ── Frontend ── +// Test pipeline (SSE streaming) +app.get('/api/test', requireAuth, (c) => { + const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/'; + + return streamSSE(c, async (stream) => { + const send = async (step: string, status: 'ok' | 'fail' | 'info', detail: string) => { + await stream.writeSSE({ data: JSON.stringify({ step, status, detail }) }); + }; + + const apifyToken = getConfig('APIFY_TOKEN'); + const llmApiKey = getConfig('LLM_API_KEY'); + if (!apifyToken) { await send('config', 'fail', 'APIFY_TOKEN 未設定'); return; } + if (!llmApiKey) { await send('config', 'fail', 'LLM_API_KEY 未設定'); return; } + await send('config', 'ok', '設定檢查通過'); + + let post: any; + try { + await send('fetch', 'info', '正在抓取貼文...'); + const posts = await fetchFacebookPosts(FB_PAGE_URL, apifyToken, 1); + if (posts.length === 0) { await send('fetch', 'fail', '沒有抓到貼文'); return; } + post = posts[0]; + const preview = (post.text || '(純圖片)').slice(0, 80); + await send('fetch', 'ok', preview); + } catch (err: any) { + await send('fetch', 'fail', err.message); return; + } + + let analysis: any; + try { + await send('analyze', 'info', '正在進行 AI 分析...'); + const localTime = new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }); + let content = `[Facebook] ${post.text}`; + if (post.ocrText) content += `\n[圖片 OCR] ${post.ocrText}`; + if (post.captionText) content += `\n[影片轉錄] ${post.captionText}`; + analysis = await analyzePosts( + [{ text: content, timestamp: localTime, isToday: true }], + { + baseUrl: getConfig('LLM_BASE_URL') || 'https://api.deepinfra.com/v1/openai', + apiKey: llmApiKey, + model: getConfig('LLM_MODEL') || 'MiniMaxAI/MiniMax-M2.5', + }, + ); + await send('analyze', 'ok', analysis.summary || '分析完成'); + } catch (err: any) { + await send('analyze', 'fail', err.message); return; + } + + const notifiers = createNotifiers(); + if (notifiers.length === 0) { + await send('notify', 'fail', '未設定任何通知管道(Telegram / Discord / LINE)'); + return; + } + await send('notify', 'info', `正在推送至 ${notifiers.map(n => n.name).join(', ')}...`); + const postSummary: PostSummary = { + source: 'facebook', + timestamp: new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }), + isToday: true, + text: (post.text || '').slice(0, 60), + url: post.url, + }; + const reportData: ReportData = { + analysis, postCount: { fb: 1 }, posts: [postSummary], isFallback: false, + }; + const results = await Promise.allSettled(notifiers.map((n) => n.send(reportData))); + const sent: string[] = []; + const failed: string[] = []; + for (let i = 0; i < results.length; i++) { + if (results[i].status === 'fulfilled') { + sent.push(notifiers[i].name); + } else { + const reason = (results[i] as PromiseRejectedResult).reason; + failed.push(`${notifiers[i].name}: ${reason instanceof Error ? reason.message : reason}`); + } + } + if (failed.length > 0) { + await send('notify', 'fail', `失敗:${failed.join('; ')}`); + } else { + await send('notify', 'ok', `已送出:${sent.join(', ')}`); + } + await send('done', 'ok', '測試完成'); + }); +}); + +// -- Frontend -- app.get('/', (c) => { return c.html(FRONTEND_HTML); }); -// ── Start server ── +// -- Start server -- const WEB_PORT = parseInt(process.env.WEB_PORT || '3000', 10); export function startWebServer(): void { @@ -106,99 +186,294 @@ export function startWebServer(): void { }); } -// ── Inline frontend ── -const FRONTEND_HTML = ` +// -- Inline frontend -- +const FRONTEND_HTML = /* html */ ` -banini-tracker 設定 +banini-tracker + + + -
-

banini-tracker 設定

- - -