{"repo": "cablate/banini-tracker", "files": {"src/web.ts": {"850297c2003a653023cc36aeb5b63683e49e7c5b": {"t": 1785674595.7844791, "n": 31004, "h": "df73295528fb1b92", "c": "/**\n * Web config panel: Hono server with auth + config CRUD.\n * Single HTML page, no build step.\n */\nimport { Hono } from 'hono';\nimport type { MiddlewareHandler } from 'hono';\nimport { streamSSE } from 'hono/streaming';\nimport { serve } from '@hono/node-server';\nimport { getCookie, setCookie, deleteCookie } from 'hono/cookie';\nimport { isInitialized, setupAdmin, login, logout, validateSession } from './auth.js';\nimport { getAllConfig, setConfigs, getConfigKeys, getConfig, type ConfigKey } from './config-store.js';\nimport { fetchFacebookPosts } from './facebook.js';\nimport { getRecentRuns, getRecentNotifications } from './run-logger.js';\nimport { analyzePosts, DEFAULT_SYSTEM_PROMPT } from './analyze.js';\nimport { createNotifiers, type ReportData, type PostSummary } from './notifiers/index.js';\n\nconst app = new Hono();\n\nconst isProduction = process.env.NODE_ENV === 'production';\n\n// -- Auth middleware --\nconst requireAuth: MiddlewareHandler = async (c, next) => {\n const token = getCookie(c, 'session');\n if (!token || !validateSession(token)) {\n return c.json({ error: 'unauthorized' }, 401);\n }\n await next();\n};\n\n// -- API routes --\n\napp.get('/api/status', (c) => {\n return c.json({ initialized: isInitialized() });\n});\n\napp.post('/api/setup', async (c) => {\n if (isInitialized()) return c.json({ error: 'already initialized' }, 400);\n const { username, password } = await c.req.json();\n try {\n setupAdmin(username, password);\n const token = login(username, password);\n if (token) setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' });\n return c.json({ ok: true });\n } catch (err: any) {\n return c.json({ error: err.message }, 400);\n }\n});\n\napp.post('/api/login', async (c) => {\n const { username, password } = await c.req.json();\n const token = login(username, password);\n if (!token) return c.json({ error: 'invalid credentials' }, 401);\n setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' });\n return c.json({ ok: true });\n});\n\napp.post('/api/logout', (c) => {\n const token = getCookie(c, 'session');\n if (token) logout(token);\n deleteCookie(c, 'session', { path: '/' });\n return c.json({ ok: true });\n});\n\napp.get('/api/config', requireAuth, (c) => {\n const config = getAllConfig();\n const masked: Record = {};\n for (const [key, value] of Object.entries(config)) {\n if (value && (key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'))) {\n masked[key] = value.slice(0, 6) + '***';\n } else {\n masked[key] = value;\n }\n }\n // SYSTEM_PROMPT 空值時回傳預設值,讓前端直接看到\n if (!masked['SYSTEM_PROMPT']) {\n masked['SYSTEM_PROMPT'] = DEFAULT_SYSTEM_PROMPT;\n }\n return c.json({ config: masked, keys: getConfigKeys() });\n});\n\napp.put('/api/config', requireAuth, async (c) => {\n const body = await c.req.json();\n if (typeof body !== 'object' || body === null || Array.isArray(body)) {\n return c.json({ error: 'invalid payload' }, 400);\n }\n const entries: Partial> = {};\n for (const [key, value] of Object.entries(body)) {\n if (typeof value !== 'string') {\n return c.json({ error: `invalid value for ${key}: expected string` }, 400);\n }\n entries[key as ConfigKey] = value;\n }\n setConfigs(entries);\n return c.json({ ok: true });\n});\n\napp.get('/api/runs', requireAuth, (c) => {\n const runs = getRecentRuns(50);\n return c.json({ runs });\n});\n\napp.get('/api/notifications', requireAuth, (c) => {\n const notifications = getRecentNotifications(100);\n return c.json({ notifications });\n});\n\napp.get('/api/default-prompt', requireAuth, (c) => {\n return c.json({ prompt: DEFAULT_SYSTEM_PROMPT });\n});\n\n// Test pipeline (SSE streaming)\napp.get('/api/test', requireAuth, (c) => {\n const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';\n\n return streamSSE(c, async (stream) => {\n const send = async (step: string, status: 'ok' | 'fail' | 'info', detail: string) => {\n await stream.writeSSE({ data: JSON.stringify({ step, status, detail }) });\n };\n\n const apifyToken = getConfig('APIFY_TOKEN');\n const llmApiKey = getConfig('LLM_API_KEY');\n if (!apifyToken) { await send('config', 'fail', 'APIFY_TOKEN 未設定'); return; }\n if (!llmApiKey) { await send('config', 'fail', 'LLM_API_KEY 未設定'); return; }\n await send('config', 'ok', '設定檢查通過');\n\n let post: any;\n try {\n await send('fetch', 'info', '正在抓取貼文...');\n const posts = await fetchFacebookPosts(FB_PAGE_URL, apifyToken, 1);\n if (posts.length === 0) { await send('fetch', 'fail', '沒有抓到貼文'); return; }\n post = posts[0];\n const preview = (post.text || '(純圖片)').slice(0, 80);\n await send('fetch', 'ok', preview);\n } catch (err: any) {\n await send('fetch', 'fail', err.message); return;\n }\n\n let analysis: any;\n try {\n await send('analyze', 'info', '正在進行 AI 分析...');\n const localTime = new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });\n let content = `[Facebook] ${post.text}`;\n if (post.ocrText) content += `\\n[圖片 OCR] ${post.ocrText}`;\n if (post.captionText) content += `\\n[影片轉錄] ${post.captionText}`;\n analysis = await analyzePosts(\n [{ text: content, timestamp: localTime, isToday: true }],\n {\n baseUrl: getConfig('LLM_BASE_URL') || 'https://api.deepinfra.com/v1/openai',\n apiKey: llmApiKey,\n model: getConfig('LLM_MODEL') || 'MiniMaxAI/MiniMax-M2.5',\n },\n );\n await send('analyze', 'ok', analysis.summary || '分析完成');\n } catch (err: any) {\n await send('analyze', 'fail', err.message); return;\n }\n\n const notifiers = createNotifiers();\n if (notifiers.length === 0) {\n await send('notify', 'fail', '未設定任何通知管道(Telegram / Discord / LINE)');\n return;\n }\n await send('notify', 'info', `正在推送至 ${notifiers.map(n => n.name).join(', ')}...`);\n const postSummary: PostSummary = {\n source: 'facebook',\n timestamp: new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }),\n isToday: true,\n text: (post.text || '').slice(0, 60),\n url: post.url,\n };\n const reportData: ReportData = {\n analysis, postCount: { fb: 1 }, posts: [postSummary], isFallback: false,\n };\n const results = await Promise.allSettled(notifiers.map((n) => n.send(reportData)));\n const sent: string[] = [];\n const failed: string[] = [];\n for (let i = 0; i < results.length; i++) {\n if (results[i].status === 'fulfilled') {\n sent.push(notifiers[i].name);\n } else {\n const reason = (results[i] as PromiseRejectedResult).reason;\n failed.push(`${notifiers[i].name}: ${reason instanceof Error ? reason.message : reason}`);\n }\n }\n if (failed.length > 0) {\n await send('notify', 'fail', `失敗:${failed.join('; ')}`);\n } else {\n await send('notify', 'ok', `已送出:${sent.join(', ')}`);\n }\n await send('done', 'ok', '測試完成');\n });\n});\n\n// -- Frontend --\napp.get('/', (c) => {\n return c.html(FRONTEND_HTML);\n});\n\n// -- Start server --\nconst WEB_PORT = parseInt(process.env.WEB_PORT || '3000', 10);\n\nexport function startWebServer(): void {\n serve({ fetch: app.fetch, port: WEB_PORT }, () => {\n console.log(`[Web] 設定頁面已啟動: http://localhost:${WEB_PORT}`);\n });\n}\n\n// -- Inline frontend --\nconst FRONTEND_HTML = /* html */ `\n\n\n\n\nbanini-tracker\n\n\n\n\n\n\n\n\n
\n
\n
Login
\n
\n
\n
\n
\n
\n
\n\n\n
\n
\n
\n

banini-tracker config

\n \n
\n
\n
必填
\n
\n
\n
\n
Telegram
\n
\n
\n
\n
Discord
\n
\n
\n
\n
LINE
\n
\n
\n
\n
進階
\n
\n
\n
\n
\n
\n

Base URL 預設 DeepInfra / Model 預設 MiniMax-M2.5

\n
\n
分析提示詞
\n
\n
\n \n \n
\n \n

自訂 LLM 分析提示詞。留空時使用內建預設值。

\n
\n
\n
\n
\n \n \n
\n
\n
\n
\n\n
\n
\n
\n \n \n \n
\n
\n \n \n
\n
\n
\n
等待操作
\n
\n
\n
載入中...
\n
\n
\n
載入中...
\n
\n
\n
\n\n\n\n`;\n"}}}}