Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ function migrate(db: Database.Database): void {
change_pct_low REAL NOT NULL,
UNIQUE(prediction_id, date)
);

CREATE TABLE IF NOT EXISTS run_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
posts_found INTEGER NOT NULL DEFAULT 0,
posts_new INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
summary TEXT
);

CREATE TABLE IF NOT EXISTS notification_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER REFERENCES run_logs(id),
channel TEXT NOT NULL,
status TEXT NOT NULL,
error_message TEXT,
sent_at TEXT NOT NULL
);
`);
}

Expand Down
30 changes: 27 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { filterNewPosts as filterNew } from './seen.js';
import { withRetry } from './retry.js';
import { createTranscriber, transcribeVideoPosts, type TranscriberType } from './transcribe.js';
import { recordPredictions, updateTracking } from './tracker.js';
import { startRun, endRun, logNotification } from './run-logger.js';
import { getDb } from './db.js';
import { getConfig, initConfigTable, type ConfigKey } from './config-store.js';
import { startWebServer } from './web.js';
Expand Down Expand Up @@ -81,17 +82,32 @@ interface RunOptions {
async function run(opts: RunOptions) {
if (running) {
console.log(`[${opts.label}] 上一次還在跑,跳過本次排程`);
const skipId = startRun(opts.label);
endRun(skipId, 'skip', { summary: '上一次還在跑,跳過' });
return;
}
running = true;
const runId = startRun(opts.label);
try {
await runInner(opts);
await runInner(opts, runId);
endRun(runId, 'ok', {
postsFound: (opts as any)._postsFound ?? 0,
postsNew: (opts as any)._postsNew ?? 0,
summary: (opts as any)._summary ?? null,
});
} catch (err) {
endRun(runId, 'fail', {
postsFound: (opts as any)._postsFound ?? 0,
postsNew: (opts as any)._postsNew ?? 0,
error: err instanceof Error ? err.message : String(err),
});
throw err;
} finally {
running = false;
}
}

async function runInner(opts: RunOptions) {
async function runInner(opts: RunOptions, runId: number) {
const now = new Date().toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
console.log(`\n=== 巴逆逆反指標追蹤器 [${opts.label}] ${now} ===\n`);

Expand Down Expand Up @@ -120,16 +136,20 @@ async function runInner(opts: RunOptions) {
console.error(`[Facebook] 抓取失敗: ${err instanceof Error ? err.message : err}`);
}

(opts as any)._postsFound = allPosts.length;
if (allPosts.length === 0) {
console.log('沒有抓到任何貼文,結束');
(opts as any)._summary = '沒有抓到貼文';
return;
}

// 3. 去重(查 SQLite posts 表)
const newPosts = filterNew(allPosts);

(opts as any)._postsNew = newPosts.length;
if (newPosts.length === 0) {
console.log('沒有新貼文,結束');
(opts as any)._summary = '沒有新貼文';
return;
}

Expand Down Expand Up @@ -254,6 +274,7 @@ async function runInner(opts: RunOptions) {
console.log(' 巴逆逆反指標分析報告');
console.log('========================================\n');
console.log(`摘要: ${analysis.summary}`);
(opts as any)._summary = analysis.summary;

if (analysis.hasInvestmentContent) {
if (analysis.mentionedTargets?.length) {
Expand Down Expand Up @@ -300,8 +321,11 @@ async function runInner(opts: RunOptions) {
const r = results[i];
if (r.status === 'fulfilled') {
console.log(`[${notifiers[i].name}] 通知已發送`);
logNotification(runId, notifiers[i].name, 'sent');
} else {
console.error(`[${notifiers[i].name}] 發送失敗(已重試 3 次): ${r.reason instanceof Error ? r.reason.message : r.reason}`);
const errMsg = r.reason instanceof Error ? r.reason.message : String(r.reason);
console.error(`[${notifiers[i].name}] 發送失敗(已重試 3 次): ${errMsg}`);
logNotification(runId, notifiers[i].name, 'failed', errMsg);
}
}
} else {
Expand Down
89 changes: 89 additions & 0 deletions src/run-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Run & notification logging — writes to SQLite for Web UI display.
*/
import { getDb } from './db.js';

export interface RunLog {
id: number;
label: string;
started_at: string;
ended_at: string | null;
status: 'running' | 'ok' | 'fail' | 'skip';
posts_found: number;
posts_new: number;
error_message: string | null;
summary: string | null;
}

export function startRun(label: string): number {
const db = getDb();
const now = new Date().toISOString();
const result = db.prepare(
'INSERT INTO run_logs (label, started_at, status) VALUES (?, ?, ?)',
).run(label, now, 'running');
return Number(result.lastInsertRowid);
}

export function endRun(
runId: number,
status: 'ok' | 'fail' | 'skip',
details: { postsFound?: number; postsNew?: number; error?: string; summary?: string } = {},
): void {
const db = getDb();
const now = new Date().toISOString();
db.prepare(`
UPDATE run_logs SET
ended_at = ?, status = ?,
posts_found = ?, posts_new = ?,
error_message = ?, summary = ?
WHERE id = ?
`).run(
now, status,
details.postsFound ?? 0, details.postsNew ?? 0,
details.error ?? null, details.summary ?? null,
runId,
);
}

export function logNotification(
runId: number,
channel: string,
status: 'sent' | 'failed',
error?: string,
): void {
const db = getDb();
const now = new Date().toISOString();
db.prepare(
'INSERT INTO notification_logs (run_id, channel, status, error_message, sent_at) VALUES (?, ?, ?, ?, ?)',
).run(runId, channel, status, error ?? null, now);
}

export function getRecentRuns(limit = 50): RunLog[] {
const db = getDb();
return db.prepare(
'SELECT * FROM run_logs ORDER BY id DESC LIMIT ?',
).all(limit) as RunLog[];
}

export interface NotificationLog {
id: number;
run_id: number;
channel: string;
status: string;
error_message: string | null;
sent_at: string;
}

export function getRecentNotifications(limit = 100): NotificationLog[] {
const db = getDb();
return db.prepare(
'SELECT * FROM notification_logs ORDER BY id DESC LIMIT ?',
).all(limit) as NotificationLog[];
}

export function getNotificationsForRun(runId: number): NotificationLog[] {
const db = getDb();
return db.prepare(
'SELECT * FROM notification_logs WHERE run_id = ? ORDER BY id',
).all(runId) as NotificationLog[];
}
131 changes: 128 additions & 3 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { isInitialized, setupAdmin, login, logout, validateSession } from './auth.js';
import { getAllConfig, setConfigs, getConfigKeys, getConfig, type ConfigKey } from './config-store.js';
import { fetchFacebookPosts } from './facebook.js';
import { getRecentRuns, getRecentNotifications } from './run-logger.js';
import { analyzePosts, DEFAULT_SYSTEM_PROMPT } from './analyze.js';
import { createNotifiers, type ReportData, type PostSummary } from './notifiers/index.js';

Expand Down Expand Up @@ -93,6 +94,16 @@ app.put('/api/config', requireAuth, async (c) => {
return c.json({ ok: true });
});

app.get('/api/runs', requireAuth, (c) => {
const runs = getRecentRuns(50);
return c.json({ runs });
});

app.get('/api/notifications', requireAuth, (c) => {
const notifications = getRecentNotifications(100);
return c.json({ notifications });
});

app.get('/api/default-prompt', requireAuth, (c) => {
return c.json({ prompt: DEFAULT_SYSTEM_PROMPT });
});
Expand Down Expand Up @@ -290,6 +301,38 @@ const FRONTEND_HTML = /* html */ `<!DOCTYPE html>
font-family: var(--mono); font-size: 0.8rem; font-weight: 600;
color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em;
}
.tab-group { display: flex; gap: 0.2rem; }
.tab-btn {
font-family: var(--mono); font-size: 0.8rem; font-weight: 600;
color: var(--text-muted); background: transparent; border: none;
padding: 0.3rem 0.7rem; border-radius: 6px; cursor: pointer;
transition: all var(--transition);
}
.tab-btn:hover { color: var(--text-secondary); background: var(--surface-alt); }
.tab-btn.active { color: var(--accent); background: var(--accent-subtle); }
.run-row {
padding: 0.5rem 2rem; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 0.75rem;
font-size: 0.85rem; transition: background var(--transition);
}
.run-row:hover { background: rgba(0,0,0,0.02); }
.run-status {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.run-status.ok { background: var(--accent); }
.run-status.fail { background: var(--red); }
.run-status.skip { background: var(--text-muted); }
.run-status.running { background: var(--amber); }
.run-label { font-weight: 600; color: var(--text); min-width: 3.5rem; }
.run-meta { color: var(--text-muted); font-family: var(--mono); font-size: 0.8rem; }
.run-summary { color: var(--text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notif-row {
padding: 0.5rem 2rem; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 0.75rem;
font-size: 0.85rem; transition: background var(--transition);
}
.notif-row:hover { background: rgba(0,0,0,0.02); }
.notif-channel { font-weight: 600; color: var(--text); min-width: 5rem; }
.log-actions { display: flex; gap: 0.4rem; }
.log-body {
flex: 1; overflow-y: auto; padding: 0.75rem 0;
Expand Down Expand Up @@ -496,15 +539,25 @@ const FRONTEND_HTML = /* html */ `<!DOCTYPE html>

<div class="col-right">
<div class="log-bar">
<span class="log-bar-title">Log</span>
<div class="tab-group">
<button class="tab-btn active" data-tab="log" onclick="switchTab('log')">Log</button>
<button class="tab-btn" data-tab="runs" onclick="switchTab('runs')">排程</button>
<button class="tab-btn" data-tab="notifs" onclick="switchTab('notifs')">推送</button>
</div>
<div class="log-actions">
<button class="btn-ghost btn-sm" onclick="clearLog()">清除</button>
<button class="btn-ghost btn-sm" id="copy-btn" onclick="copyLog()">複製</button>
<button class="btn-ghost btn-sm" id="tab-action-1" onclick="clearLog()">清除</button>
<button class="btn-ghost btn-sm" id="tab-action-2" onclick="copyLog()">複製</button>
</div>
</div>
<div class="log-body" id="log-body">
<div class="log-empty">等待操作</div>
</div>
<div class="log-body" id="runs-body" style="display:none">
<div class="log-empty">載入中...</div>
</div>
<div class="log-body" id="notifs-body" style="display:none">
<div class="log-empty">載入中...</div>
</div>
</div>
</div>

Expand Down Expand Up @@ -677,6 +730,78 @@ function toast(id, text, isErr) {
setTimeout(() => { el.textContent = ''; el.className = ''; }, 4000);
}

let currentTab = 'log';
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
$('log-body').style.display = tab === 'log' ? '' : 'none';
$('runs-body').style.display = tab === 'runs' ? '' : 'none';
$('notifs-body').style.display = tab === 'notifs' ? '' : 'none';
// Update action buttons
const a1 = $('tab-action-1'), a2 = $('tab-action-2');
if (tab === 'log') {
a1.textContent = '清除'; a1.onclick = clearLog;
a2.textContent = '複製'; a2.onclick = copyLog;
a1.style.display = ''; a2.style.display = '';
} else if (tab === 'runs') {
a1.textContent = '重新整理'; a1.onclick = loadRuns;
a2.style.display = 'none';
loadRuns();
} else {
a1.textContent = '重新整理'; a1.onclick = loadNotifs;
a2.style.display = 'none';
loadNotifs();
}
}

function fmtTime(iso) {
if (!iso) return '-';
return new Date(iso).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', hour12: false, month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });
}

async function loadRuns() {
const body = $('runs-body');
body.innerHTML = '<div class="log-empty">載入中...</div>';
try {
const res = await fetch('/api/runs');
const { runs } = await res.json();
if (runs.length === 0) { body.innerHTML = '<div class="log-empty">尚無排程紀錄</div>'; return; }
body.innerHTML = '';
for (const r of runs) {
const el = document.createElement('div');
el.className = 'run-row';
const dur = r.ended_at ? ((new Date(r.ended_at) - new Date(r.started_at)) / 1000).toFixed(0) + 's' : '...';
el.innerHTML = '<div class="run-status ' + r.status + '"></div>'
+ '<span class="run-label">' + esc(r.label) + '</span>'
+ '<span class="run-meta">' + fmtTime(r.started_at) + ' (' + dur + ')</span>'
+ '<span class="run-summary">' + esc(r.summary || r.error_message || (r.posts_new > 0 ? r.posts_new + ' 篇新貼文' : '')) + '</span>'
+ '<span class="run-meta">' + r.posts_found + '/' + r.posts_new + '</span>';
body.appendChild(el);
}
} catch { body.innerHTML = '<div class="log-empty">載入失敗</div>'; }
}

async function loadNotifs() {
const body = $('notifs-body');
body.innerHTML = '<div class="log-empty">載入中...</div>';
try {
const res = await fetch('/api/notifications');
const { notifications } = await res.json();
if (notifications.length === 0) { body.innerHTML = '<div class="log-empty">尚無推送紀錄</div>'; return; }
body.innerHTML = '';
for (const n of notifications) {
const el = document.createElement('div');
el.className = 'notif-row';
const statusCls = n.status === 'sent' ? 'log-ok' : 'log-fail';
el.innerHTML = '<div class="run-status ' + (n.status === 'sent' ? 'ok' : 'fail') + '"></div>'
+ '<span class="notif-channel">' + esc(n.channel) + '</span>'
+ '<span class="run-meta">' + fmtTime(n.sent_at) + '</span>'
+ '<span class="' + statusCls + '">' + esc(n.status === 'sent' ? '成功' : n.error_message || '失敗') + '</span>';
body.appendChild(el);
}
} catch { body.innerHTML = '<div class="log-empty">載入失敗</div>'; }
}

init();
</script>
</body>
Expand Down
Loading