@cablate/banini-tracker 2.0.18 → 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/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/dist/web.js
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web config panel: Hono server with auth + config CRUD.
|
|
3
|
+
* Single HTML page, no build step.
|
|
4
|
+
*/
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { streamSSE } from 'hono/streaming';
|
|
7
|
+
import { serve } from '@hono/node-server';
|
|
8
|
+
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
|
9
|
+
import { isInitialized, setupAdmin, login, logout, validateSession } from './auth.js';
|
|
10
|
+
import { getAllConfig, setConfigs, getConfigKeys, getConfig } from './config-store.js';
|
|
11
|
+
import { fetchFacebookPosts } from './facebook.js';
|
|
12
|
+
import { analyzePosts } from './analyze.js';
|
|
13
|
+
import { createNotifiers } from './notifiers/index.js';
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
16
|
+
// -- Auth middleware --
|
|
17
|
+
const requireAuth = async (c, next) => {
|
|
18
|
+
const token = getCookie(c, 'session');
|
|
19
|
+
if (!token || !validateSession(token)) {
|
|
20
|
+
return c.json({ error: 'unauthorized' }, 401);
|
|
21
|
+
}
|
|
22
|
+
await next();
|
|
23
|
+
};
|
|
24
|
+
// -- API routes --
|
|
25
|
+
app.get('/api/status', (c) => {
|
|
26
|
+
return c.json({ initialized: isInitialized() });
|
|
27
|
+
});
|
|
28
|
+
app.post('/api/setup', async (c) => {
|
|
29
|
+
if (isInitialized())
|
|
30
|
+
return c.json({ error: 'already initialized' }, 400);
|
|
31
|
+
const { username, password } = await c.req.json();
|
|
32
|
+
try {
|
|
33
|
+
setupAdmin(username, password);
|
|
34
|
+
const token = login(username, password);
|
|
35
|
+
if (token)
|
|
36
|
+
setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' });
|
|
37
|
+
return c.json({ ok: true });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return c.json({ error: err.message }, 400);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
app.post('/api/login', async (c) => {
|
|
44
|
+
const { username, password } = await c.req.json();
|
|
45
|
+
const token = login(username, password);
|
|
46
|
+
if (!token)
|
|
47
|
+
return c.json({ error: 'invalid credentials' }, 401);
|
|
48
|
+
setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' });
|
|
49
|
+
return c.json({ ok: true });
|
|
50
|
+
});
|
|
51
|
+
app.post('/api/logout', (c) => {
|
|
52
|
+
const token = getCookie(c, 'session');
|
|
53
|
+
if (token)
|
|
54
|
+
logout(token);
|
|
55
|
+
deleteCookie(c, 'session', { path: '/' });
|
|
56
|
+
return c.json({ ok: true });
|
|
57
|
+
});
|
|
58
|
+
app.get('/api/config', requireAuth, (c) => {
|
|
59
|
+
const config = getAllConfig();
|
|
60
|
+
const masked = {};
|
|
61
|
+
for (const [key, value] of Object.entries(config)) {
|
|
62
|
+
if (value && (key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'))) {
|
|
63
|
+
masked[key] = value.slice(0, 6) + '***';
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
masked[key] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return c.json({ config: masked, keys: getConfigKeys() });
|
|
70
|
+
});
|
|
71
|
+
app.put('/api/config', requireAuth, async (c) => {
|
|
72
|
+
const body = await c.req.json();
|
|
73
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
74
|
+
return c.json({ error: 'invalid payload' }, 400);
|
|
75
|
+
}
|
|
76
|
+
const entries = {};
|
|
77
|
+
for (const [key, value] of Object.entries(body)) {
|
|
78
|
+
if (typeof value !== 'string') {
|
|
79
|
+
return c.json({ error: `invalid value for ${key}: expected string` }, 400);
|
|
80
|
+
}
|
|
81
|
+
entries[key] = value;
|
|
82
|
+
}
|
|
83
|
+
setConfigs(entries);
|
|
84
|
+
return c.json({ ok: true });
|
|
85
|
+
});
|
|
86
|
+
// Test pipeline (SSE streaming)
|
|
87
|
+
app.get('/api/test', requireAuth, (c) => {
|
|
88
|
+
const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';
|
|
89
|
+
return streamSSE(c, async (stream) => {
|
|
90
|
+
const send = async (step, status, detail) => {
|
|
91
|
+
await stream.writeSSE({ data: JSON.stringify({ step, status, detail }) });
|
|
92
|
+
};
|
|
93
|
+
const apifyToken = getConfig('APIFY_TOKEN');
|
|
94
|
+
const llmApiKey = getConfig('LLM_API_KEY');
|
|
95
|
+
if (!apifyToken) {
|
|
96
|
+
await send('config', 'fail', 'APIFY_TOKEN 未設定');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!llmApiKey) {
|
|
100
|
+
await send('config', 'fail', 'LLM_API_KEY 未設定');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await send('config', 'ok', '設定檢查通過');
|
|
104
|
+
let post;
|
|
105
|
+
try {
|
|
106
|
+
await send('fetch', 'info', '正在抓取貼文...');
|
|
107
|
+
const posts = await fetchFacebookPosts(FB_PAGE_URL, apifyToken, 1);
|
|
108
|
+
if (posts.length === 0) {
|
|
109
|
+
await send('fetch', 'fail', '沒有抓到貼文');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
post = posts[0];
|
|
113
|
+
const preview = (post.text || '(純圖片)').slice(0, 80);
|
|
114
|
+
await send('fetch', 'ok', preview);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
await send('fetch', 'fail', err.message);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let analysis;
|
|
121
|
+
try {
|
|
122
|
+
await send('analyze', 'info', '正在進行 AI 分析...');
|
|
123
|
+
const localTime = new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
|
|
124
|
+
let content = `[Facebook] ${post.text}`;
|
|
125
|
+
if (post.ocrText)
|
|
126
|
+
content += `\n[圖片 OCR] ${post.ocrText}`;
|
|
127
|
+
if (post.captionText)
|
|
128
|
+
content += `\n[影片轉錄] ${post.captionText}`;
|
|
129
|
+
analysis = await analyzePosts([{ text: content, timestamp: localTime, isToday: true }], {
|
|
130
|
+
baseUrl: getConfig('LLM_BASE_URL') || 'https://api.deepinfra.com/v1/openai',
|
|
131
|
+
apiKey: llmApiKey,
|
|
132
|
+
model: getConfig('LLM_MODEL') || 'MiniMaxAI/MiniMax-M2.5',
|
|
133
|
+
});
|
|
134
|
+
await send('analyze', 'ok', analysis.summary || '分析完成');
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
await send('analyze', 'fail', err.message);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const notifiers = createNotifiers();
|
|
141
|
+
if (notifiers.length === 0) {
|
|
142
|
+
await send('notify', 'fail', '未設定任何通知管道(Telegram / Discord / LINE)');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await send('notify', 'info', `正在推送至 ${notifiers.map(n => n.name).join(', ')}...`);
|
|
146
|
+
const postSummary = {
|
|
147
|
+
source: 'facebook',
|
|
148
|
+
timestamp: new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }),
|
|
149
|
+
isToday: true,
|
|
150
|
+
text: (post.text || '').slice(0, 60),
|
|
151
|
+
url: post.url,
|
|
152
|
+
};
|
|
153
|
+
const reportData = {
|
|
154
|
+
analysis, postCount: { fb: 1 }, posts: [postSummary], isFallback: false,
|
|
155
|
+
};
|
|
156
|
+
const results = await Promise.allSettled(notifiers.map((n) => n.send(reportData)));
|
|
157
|
+
const sent = [];
|
|
158
|
+
const failed = [];
|
|
159
|
+
for (let i = 0; i < results.length; i++) {
|
|
160
|
+
if (results[i].status === 'fulfilled') {
|
|
161
|
+
sent.push(notifiers[i].name);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const reason = results[i].reason;
|
|
165
|
+
failed.push(`${notifiers[i].name}: ${reason instanceof Error ? reason.message : reason}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (failed.length > 0) {
|
|
169
|
+
await send('notify', 'fail', `失敗:${failed.join('; ')}`);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
await send('notify', 'ok', `已送出:${sent.join(', ')}`);
|
|
173
|
+
}
|
|
174
|
+
await send('done', 'ok', '測試完成');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
// -- Frontend --
|
|
178
|
+
app.get('/', (c) => {
|
|
179
|
+
return c.html(FRONTEND_HTML);
|
|
180
|
+
});
|
|
181
|
+
// -- Start server --
|
|
182
|
+
const WEB_PORT = parseInt(process.env.WEB_PORT || '3000', 10);
|
|
183
|
+
export function startWebServer() {
|
|
184
|
+
serve({ fetch: app.fetch, port: WEB_PORT }, () => {
|
|
185
|
+
console.log(`[Web] 設定頁面已啟動: http://localhost:${WEB_PORT}`);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
// -- Inline frontend --
|
|
189
|
+
const FRONTEND_HTML = /* html */ `<!DOCTYPE html>
|
|
190
|
+
<html lang="zh-TW">
|
|
191
|
+
<head>
|
|
192
|
+
<meta charset="utf-8">
|
|
193
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
194
|
+
<title>banini-tracker</title>
|
|
195
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
196
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
197
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
198
|
+
<style>
|
|
199
|
+
:root {
|
|
200
|
+
--bg: #f8fafc;
|
|
201
|
+
--surface: #ffffff;
|
|
202
|
+
--surface-alt: #f1f5f9;
|
|
203
|
+
--border: #e2e8f0;
|
|
204
|
+
--border-hover: #cbd5e1;
|
|
205
|
+
--border-focus: #10b981;
|
|
206
|
+
--text: #18181b;
|
|
207
|
+
--text-secondary: #52525b;
|
|
208
|
+
--text-muted: #a1a1aa;
|
|
209
|
+
--accent: #10b981;
|
|
210
|
+
--accent-hover: #059669;
|
|
211
|
+
--accent-subtle: rgba(16,185,129,0.07);
|
|
212
|
+
--red: #dc2626;
|
|
213
|
+
--red-subtle: rgba(220,38,38,0.06);
|
|
214
|
+
--amber: #d97706;
|
|
215
|
+
--amber-subtle: rgba(217,119,6,0.06);
|
|
216
|
+
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
|
217
|
+
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
|
|
218
|
+
--radius: 10px;
|
|
219
|
+
--radius-lg: 14px;
|
|
220
|
+
--font: 'Outfit', system-ui, -apple-system, sans-serif;
|
|
221
|
+
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
|
222
|
+
--transition: 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
|
223
|
+
}
|
|
224
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
225
|
+
html, body { height: 100%; overflow: hidden; }
|
|
226
|
+
body {
|
|
227
|
+
font-family: var(--font); background: var(--bg); color: var(--text);
|
|
228
|
+
line-height: 1.6; -webkit-font-smoothing: antialiased;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ── Two-column layout ── */
|
|
232
|
+
.layout {
|
|
233
|
+
display: grid; grid-template-columns: 1fr 1fr;
|
|
234
|
+
height: 100vh; height: 100dvh; gap: 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ── Left: config ── */
|
|
238
|
+
.col-left {
|
|
239
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
240
|
+
background: var(--surface);
|
|
241
|
+
border-right: 1px solid var(--border);
|
|
242
|
+
}
|
|
243
|
+
.col-header {
|
|
244
|
+
padding: 1.5rem 2rem 1.25rem;
|
|
245
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
246
|
+
flex-shrink: 0; border-bottom: 1px solid var(--border);
|
|
247
|
+
}
|
|
248
|
+
.col-header h1 {
|
|
249
|
+
font-size: 1.25rem; font-weight: 700; color: var(--text);
|
|
250
|
+
letter-spacing: -0.02em; display: flex; align-items: center; gap: 0.6rem;
|
|
251
|
+
}
|
|
252
|
+
.tag {
|
|
253
|
+
font-family: var(--mono); font-size: 0.7rem; font-weight: 600;
|
|
254
|
+
padding: 0.2rem 0.6rem; background: var(--accent-subtle);
|
|
255
|
+
color: var(--accent); border-radius: 100px; letter-spacing: 0.03em;
|
|
256
|
+
}
|
|
257
|
+
.col-scroll {
|
|
258
|
+
flex: 1; overflow-y: auto; padding: 1.75rem 2rem 2rem;
|
|
259
|
+
}
|
|
260
|
+
.col-scroll::-webkit-scrollbar { width: 5px; }
|
|
261
|
+
.col-scroll::-webkit-scrollbar-track { background: transparent; }
|
|
262
|
+
.col-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
263
|
+
.col-scroll::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
|
|
264
|
+
.col-footer {
|
|
265
|
+
padding: 1rem 2rem; border-top: 1px solid var(--border);
|
|
266
|
+
flex-shrink: 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ── Right: log ── */
|
|
270
|
+
.col-right {
|
|
271
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
272
|
+
background: var(--bg);
|
|
273
|
+
}
|
|
274
|
+
.log-bar {
|
|
275
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
276
|
+
padding: 1.5rem 2rem 1.25rem;
|
|
277
|
+
border-bottom: 1px solid var(--border);
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
}
|
|
280
|
+
.log-bar-title {
|
|
281
|
+
font-family: var(--mono); font-size: 0.8rem; font-weight: 600;
|
|
282
|
+
color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em;
|
|
283
|
+
}
|
|
284
|
+
.log-actions { display: flex; gap: 0.4rem; }
|
|
285
|
+
.log-body {
|
|
286
|
+
flex: 1; overflow-y: auto; padding: 0.75rem 0;
|
|
287
|
+
font-family: var(--mono); font-size: 0.85rem; line-height: 1.9;
|
|
288
|
+
}
|
|
289
|
+
.log-body::-webkit-scrollbar { width: 5px; }
|
|
290
|
+
.log-body::-webkit-scrollbar-track { background: transparent; }
|
|
291
|
+
.log-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
292
|
+
.log-line { padding: 0.15rem 2rem; transition: background var(--transition); }
|
|
293
|
+
.log-line:hover { background: rgba(0,0,0,0.02); }
|
|
294
|
+
.log-ts { color: var(--text-muted); margin-right: 0.75rem; }
|
|
295
|
+
.log-ok { color: var(--accent); font-weight: 500; }
|
|
296
|
+
.log-fail { color: var(--red); font-weight: 500; }
|
|
297
|
+
.log-info { color: var(--text-secondary); }
|
|
298
|
+
.log-step { color: var(--amber); font-weight: 600; }
|
|
299
|
+
.log-empty {
|
|
300
|
+
color: var(--text-muted); height: 100%; display: flex;
|
|
301
|
+
align-items: center; justify-content: center;
|
|
302
|
+
font-family: var(--font); font-size: 0.95rem; opacity: 0.5;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* ── Auth overlay ── */
|
|
306
|
+
.auth-overlay {
|
|
307
|
+
position: fixed; inset: 0; background: var(--bg);
|
|
308
|
+
display: flex; align-items: center; justify-content: center; z-index: 10;
|
|
309
|
+
}
|
|
310
|
+
.auth-box {
|
|
311
|
+
width: 380px; background: var(--surface);
|
|
312
|
+
padding: 2.5rem; border-radius: var(--radius-lg);
|
|
313
|
+
box-shadow: var(--shadow-md); border: 1px solid var(--border);
|
|
314
|
+
}
|
|
315
|
+
.auth-box .auth-heading {
|
|
316
|
+
font-size: 1.35rem; font-weight: 700; color: var(--text);
|
|
317
|
+
letter-spacing: -0.02em; margin-bottom: 1.5rem;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ── Section labels ── */
|
|
321
|
+
.section {
|
|
322
|
+
font-family: var(--mono); font-size: 0.75rem; font-weight: 600;
|
|
323
|
+
color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em;
|
|
324
|
+
margin-top: 2rem; margin-bottom: 0.6rem;
|
|
325
|
+
}
|
|
326
|
+
.section:first-child { margin-top: 0; }
|
|
327
|
+
|
|
328
|
+
/* ── Fields ── */
|
|
329
|
+
.f { margin-top: 0.75rem; }
|
|
330
|
+
.f:first-child { margin-top: 0; }
|
|
331
|
+
.f label {
|
|
332
|
+
display: block; font-size: 0.875rem; font-weight: 500;
|
|
333
|
+
color: var(--text-secondary); margin-bottom: 0.35rem;
|
|
334
|
+
}
|
|
335
|
+
.f input {
|
|
336
|
+
width: 100%; padding: 0.6rem 0.85rem; font-family: var(--mono);
|
|
337
|
+
font-size: 0.875rem; color: var(--text); background: var(--surface-alt);
|
|
338
|
+
border: 1px solid var(--border); border-radius: var(--radius);
|
|
339
|
+
transition: all var(--transition); outline: none;
|
|
340
|
+
}
|
|
341
|
+
.f input:hover { border-color: var(--border-hover); }
|
|
342
|
+
.f input:focus {
|
|
343
|
+
border-color: var(--border-focus);
|
|
344
|
+
box-shadow: 0 0 0 3px var(--accent-subtle);
|
|
345
|
+
background: var(--surface);
|
|
346
|
+
}
|
|
347
|
+
.f input::placeholder { color: var(--text-muted); font-weight: 400; }
|
|
348
|
+
|
|
349
|
+
.sep { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
|
350
|
+
|
|
351
|
+
/* ── Buttons ── */
|
|
352
|
+
button {
|
|
353
|
+
font-family: var(--font); font-size: 0.875rem; font-weight: 600;
|
|
354
|
+
padding: 0.55rem 1.1rem; border: none; border-radius: var(--radius);
|
|
355
|
+
cursor: pointer; transition: all var(--transition); outline: none;
|
|
356
|
+
}
|
|
357
|
+
button:active { transform: translateY(1px) scale(0.98); }
|
|
358
|
+
button:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
|
359
|
+
.btn-primary { background: var(--accent); color: #ffffff; }
|
|
360
|
+
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
|
361
|
+
.btn-ghost {
|
|
362
|
+
background: transparent; color: var(--text-secondary);
|
|
363
|
+
border: 1px solid var(--border);
|
|
364
|
+
}
|
|
365
|
+
.btn-ghost:hover:not(:disabled) {
|
|
366
|
+
background: var(--surface-alt); color: var(--text);
|
|
367
|
+
border-color: var(--border-hover);
|
|
368
|
+
}
|
|
369
|
+
.btn-test {
|
|
370
|
+
background: var(--amber-subtle); color: var(--amber);
|
|
371
|
+
border: 1px solid rgba(217,119,6,0.15);
|
|
372
|
+
}
|
|
373
|
+
.btn-test:hover:not(:disabled) {
|
|
374
|
+
background: rgba(217,119,6,0.12);
|
|
375
|
+
border-color: rgba(217,119,6,0.3);
|
|
376
|
+
}
|
|
377
|
+
.btn-sm { font-size: 0.8rem; padding: 0.35rem 0.7rem; }
|
|
378
|
+
|
|
379
|
+
.toast {
|
|
380
|
+
padding: 0.5rem 0.85rem; border-radius: var(--radius);
|
|
381
|
+
margin-top: 0.6rem; font-size: 0.85rem; font-weight: 500;
|
|
382
|
+
}
|
|
383
|
+
.toast.ok {
|
|
384
|
+
background: var(--accent-subtle); color: var(--accent);
|
|
385
|
+
border: 1px solid rgba(16,185,129,0.15);
|
|
386
|
+
}
|
|
387
|
+
.toast.err {
|
|
388
|
+
background: var(--red-subtle); color: var(--red);
|
|
389
|
+
border: 1px solid rgba(220,38,38,0.15);
|
|
390
|
+
}
|
|
391
|
+
.hint {
|
|
392
|
+
font-size: 0.8rem; color: var(--text-muted); margin-top: 0.4rem;
|
|
393
|
+
line-height: 1.5;
|
|
394
|
+
}
|
|
395
|
+
.actions { display: flex; gap: 0.6rem; align-items: center; }
|
|
396
|
+
|
|
397
|
+
/* ── Mobile: stack ── */
|
|
398
|
+
@media (max-width: 768px) {
|
|
399
|
+
html, body { overflow: auto; }
|
|
400
|
+
.layout { grid-template-columns: 1fr; height: auto; min-height: 100dvh; }
|
|
401
|
+
.col-left { border-right: none; border-bottom: 1px solid var(--border); }
|
|
402
|
+
.col-scroll { overflow: visible; }
|
|
403
|
+
.col-right { min-height: 300px; }
|
|
404
|
+
.col-header, .log-bar { padding: 1.25rem 1.25rem 1rem; }
|
|
405
|
+
.col-scroll { padding: 1.25rem; }
|
|
406
|
+
.col-footer { padding: 1rem 1.25rem; }
|
|
407
|
+
.log-line { padding: 0.15rem 1.25rem; }
|
|
408
|
+
}
|
|
409
|
+
</style>
|
|
410
|
+
</head>
|
|
411
|
+
<body>
|
|
412
|
+
|
|
413
|
+
<!-- Auth overlay -->
|
|
414
|
+
<div id="auth-overlay" class="auth-overlay" style="display:none">
|
|
415
|
+
<div class="auth-box">
|
|
416
|
+
<div class="auth-heading" id="auth-title">Login</div>
|
|
417
|
+
<div class="f"><label>帳號</label><input id="auth-user" autocomplete="username" placeholder="admin"></div>
|
|
418
|
+
<div class="f"><label>密碼</label><input id="auth-pass" type="password" autocomplete="current-password"></div>
|
|
419
|
+
<div style="margin-top:1.25rem"><button class="btn-primary" onclick="doAuth()">確認</button></div>
|
|
420
|
+
<div id="auth-msg"></div>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
<!-- Main two-column layout -->
|
|
425
|
+
<div class="layout" id="main-layout" style="display:none">
|
|
426
|
+
<div class="col-left">
|
|
427
|
+
<div class="col-header">
|
|
428
|
+
<h1>banini-tracker <span class="tag">config</span></h1>
|
|
429
|
+
<button class="btn-ghost btn-sm" onclick="doLogout()">登出</button>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="col-scroll">
|
|
432
|
+
<div class="section">必填</div>
|
|
433
|
+
<div class="f"><label>Apify Token</label><input id="cfg-APIFY_TOKEN" placeholder="apify_api_..."></div>
|
|
434
|
+
<div class="f"><label>LLM API Key</label><input id="cfg-LLM_API_KEY" placeholder="sk-..."></div>
|
|
435
|
+
<hr class="sep">
|
|
436
|
+
<div class="section">Telegram</div>
|
|
437
|
+
<div class="f"><label>Bot Token</label><input id="cfg-TG_BOT_TOKEN" placeholder="110201543:AAHdqT..."></div>
|
|
438
|
+
<div class="f"><label>Channel ID</label><input id="cfg-TG_CHANNEL_ID" placeholder="-100..."></div>
|
|
439
|
+
<hr class="sep">
|
|
440
|
+
<div class="section">Discord</div>
|
|
441
|
+
<div class="f"><label>Bot Token</label><input id="cfg-DISCORD_BOT_TOKEN"></div>
|
|
442
|
+
<div class="f"><label>Channel ID</label><input id="cfg-DISCORD_CHANNEL_ID"></div>
|
|
443
|
+
<hr class="sep">
|
|
444
|
+
<div class="section">LINE</div>
|
|
445
|
+
<div class="f"><label>Channel Access Token</label><input id="cfg-LINE_CHANNEL_ACCESS_TOKEN"></div>
|
|
446
|
+
<div class="f"><label>To</label><input id="cfg-LINE_TO" placeholder="userId / groupId"></div>
|
|
447
|
+
<hr class="sep">
|
|
448
|
+
<div class="section">進階</div>
|
|
449
|
+
<div class="f"><label>LLM Base URL</label><input id="cfg-LLM_BASE_URL"></div>
|
|
450
|
+
<div class="f"><label>LLM Model</label><input id="cfg-LLM_MODEL"></div>
|
|
451
|
+
<div class="f"><label>Transcriber</label><input id="cfg-TRANSCRIBER" placeholder="noop / groq"></div>
|
|
452
|
+
<div class="f"><label>Groq API Key</label><input id="cfg-GROQ_API_KEY"></div>
|
|
453
|
+
<div class="f"><label>FinMind Token</label><input id="cfg-FINMIND_TOKEN"></div>
|
|
454
|
+
<p class="hint">Base URL 預設 DeepInfra / Model 預設 MiniMax-M2.5</p>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="col-footer">
|
|
457
|
+
<div class="actions">
|
|
458
|
+
<button class="btn-primary" onclick="saveConfig()">儲存</button>
|
|
459
|
+
<button class="btn-test" id="test-btn" onclick="runTest()">測試連線</button>
|
|
460
|
+
</div>
|
|
461
|
+
<div id="config-msg"></div>
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<div class="col-right">
|
|
466
|
+
<div class="log-bar">
|
|
467
|
+
<span class="log-bar-title">Log</span>
|
|
468
|
+
<div class="log-actions">
|
|
469
|
+
<button class="btn-ghost btn-sm" onclick="clearLog()">清除</button>
|
|
470
|
+
<button class="btn-ghost btn-sm" id="copy-btn" onclick="copyLog()">複製</button>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="log-body" id="log-body">
|
|
474
|
+
<div class="log-empty">等待操作</div>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<script>
|
|
480
|
+
let configKeys = [];
|
|
481
|
+
let originalValues = {};
|
|
482
|
+
let isSetup = false;
|
|
483
|
+
let logLines = [];
|
|
484
|
+
const $ = id => document.getElementById(id);
|
|
485
|
+
function ts() { return new Date().toLocaleTimeString('zh-TW', { hour12: false }); }
|
|
486
|
+
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
487
|
+
|
|
488
|
+
function appendLog(text, type) {
|
|
489
|
+
const body = $('log-body');
|
|
490
|
+
const empty = body.querySelector('.log-empty');
|
|
491
|
+
if (empty) empty.remove();
|
|
492
|
+
const el = document.createElement('div');
|
|
493
|
+
el.className = 'log-line';
|
|
494
|
+
const t = ts();
|
|
495
|
+
const cls = { ok:'log-ok', fail:'log-fail', step:'log-step', info:'log-info' }[type] || 'log-info';
|
|
496
|
+
el.innerHTML = '<span class="log-ts">' + t + '</span><span class="' + cls + '">' + esc(text) + '</span>';
|
|
497
|
+
body.appendChild(el);
|
|
498
|
+
body.scrollTop = body.scrollHeight;
|
|
499
|
+
logLines.push('[' + t + '] ' + text);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function clearLog() {
|
|
503
|
+
$('log-body').innerHTML = '<div class="log-empty">等待操作</div>';
|
|
504
|
+
logLines = [];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function copyLog() {
|
|
508
|
+
try {
|
|
509
|
+
await navigator.clipboard.writeText(logLines.join('\\n'));
|
|
510
|
+
const b = $('copy-btn'); b.textContent = '已複製';
|
|
511
|
+
setTimeout(() => { b.textContent = '複製'; }, 1200);
|
|
512
|
+
} catch {}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function init() {
|
|
516
|
+
const res = await fetch('/api/status');
|
|
517
|
+
const { initialized } = await res.json();
|
|
518
|
+
if (!initialized) {
|
|
519
|
+
isSetup = true;
|
|
520
|
+
$('auth-title').textContent = '建立管理員帳號';
|
|
521
|
+
$('auth-overlay').style.display = 'flex';
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const cfgRes = await fetch('/api/config');
|
|
525
|
+
if (cfgRes.status === 401) {
|
|
526
|
+
$('auth-title').textContent = '登入';
|
|
527
|
+
$('auth-overlay').style.display = 'flex';
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
$('main-layout').style.display = 'grid';
|
|
531
|
+
showConfig(await cfgRes.json());
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function doAuth() {
|
|
535
|
+
const user = $('auth-user').value, pass = $('auth-pass').value;
|
|
536
|
+
const url = isSetup ? '/api/setup' : '/api/login';
|
|
537
|
+
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({username:user,password:pass}) });
|
|
538
|
+
const data = await res.json();
|
|
539
|
+
if (!res.ok) { toast('auth-msg', data.error, true); return; }
|
|
540
|
+
$('auth-overlay').style.display = 'none';
|
|
541
|
+
$('main-layout').style.display = 'grid';
|
|
542
|
+
appendLog(isSetup ? '管理員帳號已建立' : '登入成功', 'ok');
|
|
543
|
+
const cfgRes = await fetch('/api/config');
|
|
544
|
+
showConfig(await cfgRes.json());
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function showConfig(data) {
|
|
548
|
+
configKeys = data.keys || [];
|
|
549
|
+
originalValues = {};
|
|
550
|
+
for (const key of configKeys) {
|
|
551
|
+
const el = $('cfg-' + key);
|
|
552
|
+
const val = data.config[key] || '';
|
|
553
|
+
if (el) el.value = val;
|
|
554
|
+
originalValues[key] = val;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function saveConfig() {
|
|
559
|
+
const body = {}; let n = 0;
|
|
560
|
+
for (const key of configKeys) {
|
|
561
|
+
const el = $('cfg-' + key);
|
|
562
|
+
const val = el ? el.value.trim() : '';
|
|
563
|
+
if (val !== originalValues[key]) { body[key] = val; n++; }
|
|
564
|
+
}
|
|
565
|
+
if (!n) { appendLog('沒有變更', 'info'); toast('config-msg','沒有變更',false); return; }
|
|
566
|
+
appendLog('儲存 ' + n + ' 項變更...', 'step');
|
|
567
|
+
const res = await fetch('/api/config', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
|
|
568
|
+
const data = await res.json();
|
|
569
|
+
if (!res.ok) { appendLog('儲存失敗:' + data.error, 'fail'); toast('config-msg', data.error, true); return; }
|
|
570
|
+
appendLog('設定已儲存', 'ok');
|
|
571
|
+
toast('config-msg', '已儲存,下次排程生效', false);
|
|
572
|
+
const cfgRes = await fetch('/api/config');
|
|
573
|
+
if (cfgRes.ok) showConfig(await cfgRes.json());
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function runTest() {
|
|
577
|
+
const btn = $('test-btn');
|
|
578
|
+
btn.disabled = true; btn.textContent = '執行中...';
|
|
579
|
+
const labels = { config:'設定檢查', fetch:'抓取貼文', analyze:'AI 分析', notify:'通知推送', done:'完成' };
|
|
580
|
+
appendLog('-- 連線測試 --', 'step');
|
|
581
|
+
try {
|
|
582
|
+
const res = await fetch('/api/test');
|
|
583
|
+
const reader = res.body.getReader();
|
|
584
|
+
const dec = new TextDecoder();
|
|
585
|
+
let buf = '';
|
|
586
|
+
while (true) {
|
|
587
|
+
const { done, value } = await reader.read();
|
|
588
|
+
if (done) break;
|
|
589
|
+
buf += dec.decode(value, { stream: true });
|
|
590
|
+
const parts = buf.split('\\n');
|
|
591
|
+
buf = parts.pop() || '';
|
|
592
|
+
for (const ln of parts) {
|
|
593
|
+
if (!ln.startsWith('data: ')) continue;
|
|
594
|
+
try {
|
|
595
|
+
const ev = JSON.parse(ln.slice(6));
|
|
596
|
+
const lbl = labels[ev.step] || ev.step;
|
|
597
|
+
if (ev.status === 'info') appendLog(lbl + ' ' + ev.detail, 'info');
|
|
598
|
+
else if (ev.status === 'ok') appendLog('[OK] ' + lbl + ' ' + ev.detail, 'ok');
|
|
599
|
+
else appendLog('[FAIL] ' + lbl + ' ' + ev.detail, 'fail');
|
|
600
|
+
} catch {}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} catch (err) { appendLog('請求失敗:' + err.message, 'fail'); }
|
|
604
|
+
appendLog('-- 測試結束 --', 'step');
|
|
605
|
+
btn.disabled = false; btn.textContent = '測試連線';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function doLogout() {
|
|
609
|
+
await fetch('/api/logout', { method:'POST' });
|
|
610
|
+
$('main-layout').style.display = 'none';
|
|
611
|
+
$('auth-title').textContent = '登入';
|
|
612
|
+
$('auth-overlay').style.display = 'flex';
|
|
613
|
+
appendLog('已登出', 'info');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function toast(id, text, isErr) {
|
|
617
|
+
const el = $(id);
|
|
618
|
+
el.className = 'toast ' + (isErr ? 'err' : 'ok');
|
|
619
|
+
el.textContent = text;
|
|
620
|
+
setTimeout(() => { el.textContent = ''; el.className = ''; }, 4000);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
init();
|
|
624
|
+
</script>
|
|
625
|
+
</body>
|
|
626
|
+
</html>`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cablate/banini-tracker",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.20",
|
|
4
4
|
"description": "巴逆逆反指標追蹤器 — 常駐排程 + CLI 雙模式",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,12 +26,14 @@
|
|
|
26
26
|
"claude"
|
|
27
27
|
],
|
|
28
28
|
"author": "cablate",
|
|
29
|
-
"license": "
|
|
29
|
+
"license": "AGPL-3.0-only",
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"@hono/node-server": "^1.19.13",
|
|
31
32
|
"better-sqlite3": "^12.8.0",
|
|
32
33
|
"commander": "^13.0.0",
|
|
33
34
|
"dotenv": "^16.4.0",
|
|
34
35
|
"groq-sdk": "^1.1.2",
|
|
36
|
+
"hono": "^4.12.12",
|
|
35
37
|
"node-cron": "^4.2.1",
|
|
36
38
|
"openai": "^4.0.0"
|
|
37
39
|
},
|