改为自动周期采集并优化首页导航

This commit is contained in:
Codex
2026-07-08 22:38:09 +08:00
parent 05d98dd576
commit da5e007324
8 changed files with 191 additions and 128 deletions
+1 -2
View File
@@ -13,8 +13,7 @@ SIGNALSCOUT_GITHUB_RECENT_DAYS=30
SIGNALSCOUT_GITHUB_MIN_STARS=20 SIGNALSCOUT_GITHUB_MIN_STARS=20
SIGNALSCOUT_GITHUB_MAX_PROJECTS=5 SIGNALSCOUT_GITHUB_MAX_PROJECTS=5
SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai
SIGNALSCOUT_AGENT_CRON_HOUR=8 SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30
SIGNALSCOUT_AGENT_CRON_MINUTE=30
SIGNALSCOUT_REPORT_DIR=reports SIGNALSCOUT_REPORT_DIR=reports
SIGNALSCOUT_LOG_FILE=logs/app.log SIGNALSCOUT_LOG_FILE=logs/app.log
SIGNALSCOUT_LOG_TAIL_LINES=200 SIGNALSCOUT_LOG_TAIL_LINES=200
+4 -3
View File
@@ -1,6 +1,6 @@
# SignalScout # SignalScout
SignalScout 是一个定时运行的 AI 情报 Agent。它先用动态新闻搜索和 GitHub 官方搜索抓取候选证据,再使用 Grok 通过中转 API 生成最新 AI 新闻、模型发布、Agent 工具、融资动态和 GitHub 热门项目日报,并把结构化情报保存到 MySQL。 SignalScout 是一个自动运行的 AI 情报 Agent。它先用动态新闻搜索和 GitHub 官方搜索抓取候选证据,再使用 Grok 通过中转 API 生成最新 AI 新闻、模型发布、Agent 工具、融资动态和 GitHub 热门项目日报,并把结构化情报保存到 MySQL。
## 主流程 ## 主流程
@@ -35,6 +35,7 @@ SIGNALSCOUT_NEWS_SEARCH_MAX_RECORDS=20
SIGNALSCOUT_GITHUB_RECENT_DAYS=30 SIGNALSCOUT_GITHUB_RECENT_DAYS=30
SIGNALSCOUT_GITHUB_MIN_STARS=20 SIGNALSCOUT_GITHUB_MIN_STARS=20
SIGNALSCOUT_GITHUB_MAX_PROJECTS=5 SIGNALSCOUT_GITHUB_MAX_PROJECTS=5
SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30
``` ```
## 本机运行 ## 本机运行
@@ -64,8 +65,8 @@ http://127.0.0.1:8010/docs
## API ## API
- `GET /health`:服务健康检查 - `GET /health`:服务健康检查
- `POST /agent/runs/daily`手动触发每日 Agent - `POST /agent/runs/daily`运维触发一次 Agent
- `POST /agent/runs/daily/stream`:以 SSE 方式触发每日 Agent - `POST /agent/runs/daily/stream`运维以 SSE 方式触发一次 Agent
- `GET /agent/runs`:查看最近运行记录 - `GET /agent/runs`:查看最近运行记录
- `GET /signals`:查看结构化情报 - `GET /signals`:查看结构化情报
- `GET /reports/latest`:查看最新日报 - `GET /reports/latest`:查看最新日报
+9 -5
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,15 +18,17 @@ def run_scheduled_job(settings: Settings) -> None:
def build_scheduler(settings: Settings) -> BackgroundScheduler: def build_scheduler(settings: Settings) -> BackgroundScheduler:
# APScheduler负责每日后台运行,服务启动后自动挂载任务 # APScheduler负责自动循环运行,启动后先跑一次,再按固定间隔持续采集
scheduler = BackgroundScheduler(timezone=settings.agent_timezone) scheduler = BackgroundScheduler(timezone=settings.agent_timezone)
scheduler.add_job( scheduler.add_job(
run_scheduled_job, run_scheduled_job,
trigger="cron", trigger="interval",
hour=settings.agent_cron_hour, minutes=settings.agent_interval_minutes,
minute=settings.agent_cron_minute,
args=[settings], args=[settings],
id="daily-intelligence", id="continuous-intelligence",
next_run_time=datetime.now(scheduler.timezone),
replace_existing=True, replace_existing=True,
max_instances=1,
coalesce=True,
) )
return scheduler return scheduler
+1 -2
View File
@@ -24,8 +24,7 @@ class Settings(BaseSettings):
github_min_stars: int = 20 github_min_stars: int = 20
github_max_projects: int = 5 github_max_projects: int = 5
agent_timezone: str = "Asia/Shanghai" agent_timezone: str = "Asia/Shanghai"
agent_cron_hour: int = 8 agent_interval_minutes: int = 30
agent_cron_minute: int = 30
report_dir: Path = Path("reports") report_dir: Path = Path("reports")
log_file: Path = Path("logs/app.log") log_file: Path = Path("logs/app.log")
log_tail_lines: int = 200 log_tail_lines: int = 200
+5 -1
View File
@@ -17,8 +17,12 @@ from app.core.log_setup import configure_logging # noqa: E402
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# The scheduler lives with the web process for the demo deployment profile. # 测试环境不启动后台采集,避免接口测试触发真实外部请求。
settings = get_settings() settings = get_settings()
if settings.env == "test":
yield
return
# 调度器随Web进程常驻,负责自动周期性整理情报。
scheduler = build_scheduler(settings) scheduler = build_scheduler(settings)
scheduler.start() scheduler.start()
app.state.scheduler = scheduler app.state.scheduler = scheduler
+150 -113
View File
@@ -15,7 +15,6 @@ HOME_PAGE_HTML = """
--panel: oklch(99% 0.006 190); --panel: oklch(99% 0.006 190);
--panel-2: oklch(94.5% 0.018 176); --panel-2: oklch(94.5% 0.018 176);
--accent: oklch(52% 0.12 174); --accent: oklch(52% 0.12 174);
--accent-ink: oklch(19% 0.035 190);
--warn: oklch(67% 0.13 70); --warn: oklch(67% 0.13 70);
--bad: oklch(58% 0.16 26); --bad: oklch(58% 0.16 26);
--good: oklch(53% 0.12 150); --good: oklch(53% 0.12 150);
@@ -79,7 +78,8 @@ HOME_PAGE_HTML = """
} }
.brand span, .brand span,
.nav-note { .nav-note,
.auto-card span {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.55;
@@ -91,17 +91,37 @@ HOME_PAGE_HTML = """
margin-top: 24px; margin-top: 24px;
} }
.nav a { .nav button {
text-decoration: none; border: 0;
width: 100%;
text-align: left;
background: transparent;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 8px;
color: var(--muted); color: var(--muted);
font: inherit;
font-size: 14px; font-size: 14px;
cursor: pointer;
} }
.nav a.active { .nav button.active {
background: var(--ink); background: var(--ink);
color: white; color: white;
font-weight: 700;
}
.auto-card {
display: grid;
gap: 6px;
margin-top: 24px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: oklch(99% 0.006 180 / 0.78);
}
.auto-card strong {
font-size: 13px;
} }
.content { .content {
@@ -134,36 +154,44 @@ HOME_PAGE_HTML = """
line-height: 1.7; line-height: 1.7;
} }
.actions { .pulse {
display: flex; min-width: 184px;
gap: 10px; border: 1px solid var(--line);
flex-wrap: wrap;
justify-content: flex-end;
}
button {
border: 1px solid var(--ink);
border-radius: 8px; border-radius: 8px;
background: var(--accent); padding: 12px 14px;
color: white; background: var(--panel);
min-height: 42px; color: var(--muted);
padding: 0 16px; line-height: 1.55;
font-weight: 700; font-size: 13px;
cursor: pointer;
box-shadow: 4px 4px 0 var(--ink);
transition: transform 160ms ease, box-shadow 160ms ease;
} }
button:hover { transform: translate(-1px, -1px); box-shadow: 5px 5px 0 var(--ink); } .pulse strong {
button:disabled { opacity: 0.58; cursor: wait; transform: none; box-shadow: 2px 2px 0 var(--ink); } display: block;
color: var(--ink);
font-size: 18px;
line-height: 1.2;
}
.layout { .layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr); grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
gap: 18px; gap: 18px;
align-items: start; align-items: start;
} }
.view {
display: none;
gap: 18px;
}
.view.active {
display: grid;
}
.view[data-view="signals"].active {
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
}
.panel { .panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -235,36 +263,28 @@ HOME_PAGE_HTML = """
.pill { .pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
height: 24px; min-height: 24px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
padding: 0 9px; padding: 2px 9px;
background: oklch(98% 0.01 180); background: oklch(98% 0.01 180);
color: var(--muted); color: var(--muted);
max-width: 100%; max-width: 100%;
} }
.importance { .importance {
color: var(--accent-ink); color: oklch(19% 0.035 190);
border-color: oklch(72% 0.08 174); border-color: oklch(72% 0.08 174);
background: oklch(91% 0.045 174); background: oklch(91% 0.045 174);
} }
.report { .report {
padding: 18px; padding: 18px;
min-height: 320px; min-height: 520px;
line-height: 1.75; line-height: 1.75;
white-space: pre-wrap; white-space: pre-wrap;
} }
.report h1,
.report h2,
.report h3 {
font-size: 16px;
margin: 0 0 10px;
line-height: 1.35;
}
.runs { display: grid; } .runs { display: grid; }
.run-row { .run-row {
@@ -291,28 +311,6 @@ HOME_PAGE_HTML = """
line-height: 1.7; line-height: 1.7;
} }
.toast {
position: fixed;
right: 22px;
bottom: 22px;
max-width: 360px;
background: var(--ink);
color: white;
padding: 12px 14px;
border-radius: 8px;
opacity: 0;
transform: translateY(12px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
line-height: 1.55;
z-index: 20;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 980px) { @media (max-width: 980px) {
.shell { grid-template-columns: 1fr; } .shell { grid-template-columns: 1fr; }
.rail { .rail {
@@ -322,9 +320,9 @@ HOME_PAGE_HTML = """
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.nav { grid-template-columns: repeat(3, minmax(0, 1fr)); } .nav { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.layout { grid-template-columns: 1fr; } .view[data-view="signals"].active { grid-template-columns: 1fr; }
.topbar { display: grid; } .topbar { display: grid; }
.actions { justify-content: flex-start; } .pulse { min-width: 0; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
@@ -332,7 +330,6 @@ HOME_PAGE_HTML = """
.rail { padding: 18px; } .rail { padding: 18px; }
.nav { grid-template-columns: 1fr; } .nav { grid-template-columns: 1fr; }
h1 { font-size: 30px; } h1 { font-size: 30px; }
button { width: 100%; }
} }
</style> </style>
</head> </head>
@@ -345,26 +342,31 @@ HOME_PAGE_HTML = """
<span>AI 情报采集与研判工作台</span> <span>AI 情报采集与研判工作台</span>
</div> </div>
<nav class="nav" aria-label="主导航"> <nav class="nav" aria-label="主导航">
<a class="active" href="#signals">情报</a> <button class="active" type="button" data-view-target="signals">情报</button>
<a href="#report">日报</a> <button type="button" data-view-target="report">日报</button>
<a href="#runs">运行</a> <button type="button" data-view-target="runs">运行</button>
</nav> </nav>
<p class="nav-note">每天自动整理 AI 新闻、模型发布、Agent 工具和 GitHub 热门项目。</p> <div class="auto-card">
<strong>自动整理</strong>
<span>启动后自动运行,之后约每 30 分钟整理一次。</span>
</div>
<p class="nav-note">聚合 AI 新闻、模型发布、Agent 工具和 GitHub 热门项目。</p>
</aside> </aside>
<main class="content"> <main class="content">
<section class="topbar"> <section class="topbar">
<div> <div>
<h1>今日 AI 情报</h1> <h1>今日 AI 情报</h1>
<p class="subtitle">聚合最新新闻和开源热度,由 Agent 统一筛选、去重和生成日报。</p> <p class="subtitle">Agent 自动筛选、去重和生成日报;页面会持续更新最新结果。</p>
</div> </div>
<div class="actions"> <div class="pulse">
<button id="runButton" type="button">采集今日情报</button> <strong id="lastRunText">等待运行</strong>
<span id="lastRunDetail">正在读取运行记录。</span>
</div> </div>
</section> </section>
<section class="layout"> <section class="view active" data-view="signals">
<div class="panel" id="signals"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>情报信号</h2> <h2>情报信号</h2>
<span class="count" id="signalCount">0 条</span> <span class="count" id="signalCount">0 条</span>
@@ -375,39 +377,65 @@ HOME_PAGE_HTML = """
</div> </div>
<div class="stack"> <div class="stack">
<div class="panel" id="report"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>最新日报</h2> <h2>最新日报</h2>
<span class="count" id="reportTime">待生成</span> <span class="count" id="reportTime">待生成</span>
</div> </div>
<div class="report" id="reportBody">正在读取日报。</div> <div class="report" id="reportPreview">正在读取日报。</div>
</div> </div>
<div class="panel" id="runs"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>最近运行</h2> <h2>最近运行</h2>
<span class="count" id="runCount">0 次</span> <span class="count" id="runCount">0 次</span>
</div> </div>
<div class="runs" id="runList"> <div class="runs" id="runPreview">
<div class="empty">正在读取运行记录。</div> <div class="empty">正在读取运行记录。</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="view" data-view="report">
<div class="panel">
<div class="panel-head">
<h2>最新日报</h2>
<span class="count" id="reportTimeFull">待生成</span>
</div>
<div class="report" id="reportBody">正在读取日报。</div>
</div>
</section>
<section class="view" data-view="runs">
<div class="panel">
<div class="panel-head">
<h2>最近运行</h2>
<span class="count" id="runCountFull">0 次</span>
</div>
<div class="runs" id="runList">
<div class="empty">正在读取运行记录。</div>
</div>
</div>
</section>
</main> </main>
</div> </div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script> <script>
const navButtons = Array.from(document.querySelectorAll("[data-view-target]"));
const views = Array.from(document.querySelectorAll("[data-view]"));
const signalList = document.querySelector("#signalList"); const signalList = document.querySelector("#signalList");
const signalCount = document.querySelector("#signalCount"); const signalCount = document.querySelector("#signalCount");
const reportPreview = document.querySelector("#reportPreview");
const reportBody = document.querySelector("#reportBody"); const reportBody = document.querySelector("#reportBody");
const reportTime = document.querySelector("#reportTime"); const reportTime = document.querySelector("#reportTime");
const reportTimeFull = document.querySelector("#reportTimeFull");
const runPreview = document.querySelector("#runPreview");
const runList = document.querySelector("#runList"); const runList = document.querySelector("#runList");
const runCount = document.querySelector("#runCount"); const runCount = document.querySelector("#runCount");
const runButton = document.querySelector("#runButton"); const runCountFull = document.querySelector("#runCountFull");
const toast = document.querySelector("#toast"); const lastRunText = document.querySelector("#lastRunText");
const lastRunDetail = document.querySelector("#lastRunDetail");
const stateText = { const stateText = {
completed: "完成", completed: "完成",
@@ -436,16 +464,16 @@ HOME_PAGE_HTML = """
}); });
} }
function showToast(message) { function activateView(name, updateHash = true) {
toast.textContent = message; navButtons.forEach((button) => button.classList.toggle("active", button.dataset.viewTarget === name));
toast.classList.add("show"); views.forEach((view) => view.classList.toggle("active", view.dataset.view === name));
window.setTimeout(() => toast.classList.remove("show"), 3200); if (updateHash) history.replaceState(null, "", `#${name}`);
} }
function renderSignals(signals) { function renderSignals(signals) {
signalCount.textContent = `${signals.length} 条`; signalCount.textContent = `${signals.length} 条`;
if (!signals.length) { if (!signals.length) {
signalList.innerHTML = '<div class="empty">还没有情报信号,可以先采集今日情报。</div>'; signalList.innerHTML = '<div class="empty">Agent 正在按固定节奏自动整理,完成后这里会出现情报信号。</div>';
return; return;
} }
signalList.innerHTML = signals.map((item) => ` signalList.innerHTML = signals.map((item) => `
@@ -468,20 +496,21 @@ HOME_PAGE_HTML = """
function renderReport(report) { function renderReport(report) {
if (!report) { if (!report) {
reportTime.textContent = "待生成"; reportTime.textContent = "待生成";
reportBody.textContent = "还没有日报,可以先采集今日情报。"; reportTimeFull.textContent = "待生成";
reportPreview.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
reportBody.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
return; return;
} }
reportTime.textContent = formatDate(report.created_at); const timeText = formatDate(report.created_at);
reportBody.textContent = report.content_md || "日报内容为空。"; const content = report.content_md || "日报内容为空。";
reportTime.textContent = timeText;
reportTimeFull.textContent = timeText;
reportPreview.textContent = content;
reportBody.textContent = content;
} }
function renderRuns(runs) { function runRows(runs) {
runCount.textContent = `${runs.length} 次`; return runs.map((run) => `
if (!runs.length) {
runList.innerHTML = '<div class="empty">还没有运行记录。</div>';
return;
}
runList.innerHTML = runs.map((run) => `
<div class="run-row"> <div class="run-row">
<span>#${escapeHtml(run.id)} · ${formatDate(run.started_at)}</span> <span>#${escapeHtml(run.id)} · ${formatDate(run.started_at)}</span>
<span class="state ${escapeHtml(run.status)}">${escapeHtml(stateText[run.status] || run.status)}</span> <span class="state ${escapeHtml(run.status)}">${escapeHtml(stateText[run.status] || run.status)}</span>
@@ -489,6 +518,23 @@ HOME_PAGE_HTML = """
`).join(""); `).join("");
} }
function renderRuns(runs) {
runCount.textContent = `${runs.length} 次`;
runCountFull.textContent = `${runs.length} 次`;
if (!runs.length) {
runPreview.innerHTML = '<div class="empty">Agent 会在启动后自动开始整理。</div>';
runList.innerHTML = '<div class="empty">Agent 会在启动后自动开始整理。</div>';
lastRunText.textContent = "等待运行";
lastRunDetail.textContent = "启动后自动运行,之后约每 30 分钟整理一次。";
return;
}
runPreview.innerHTML = runRows(runs.slice(0, 4));
runList.innerHTML = runRows(runs);
const latest = runs[0];
lastRunText.textContent = stateText[latest.status] || latest.status;
lastRunDetail.textContent = `最近一次:${formatDate(latest.started_at)}`;
}
async function loadDashboard() { async function loadDashboard() {
const response = await fetch("/dashboard", { headers: { Accept: "application/json" } }); const response = await fetch("/dashboard", { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error("读取情报失败"); if (!response.ok) throw new Error("读取情报失败");
@@ -498,32 +544,23 @@ HOME_PAGE_HTML = """
renderRuns(data.runs || []); renderRuns(data.runs || []);
} }
async function runAgent() { navButtons.forEach((button) => {
runButton.disabled = true; button.addEventListener("click", () => activateView(button.dataset.viewTarget));
runButton.textContent = "采集中"; });
showToast("Agent 已开始采集今日情报。");
try { const initialView = window.location.hash.replace("#", "");
const response = await fetch("/agent/runs/daily", { method: "POST" }); if (["signals", "report", "runs"].includes(initialView)) {
if (!response.ok) { activateView(initialView, false);
const body = await response.text();
throw new Error(body || "采集失败");
}
showToast("今日情报采集完成。");
await loadDashboard();
} catch (error) {
showToast(error.message || "采集失败,请查看日志。");
} finally {
runButton.disabled = false;
runButton.textContent = "采集今日情报";
}
} }
runButton.addEventListener("click", runAgent);
loadDashboard().catch(() => { loadDashboard().catch(() => {
signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>'; signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>';
reportPreview.textContent = "日报读取失败。";
reportBody.textContent = "日报读取失败。"; reportBody.textContent = "日报读取失败。";
runPreview.innerHTML = '<div class="empty">运行记录读取失败。</div>';
runList.innerHTML = '<div class="empty">运行记录读取失败。</div>'; runList.innerHTML = '<div class="empty">运行记录读取失败。</div>';
}); });
window.setInterval(loadDashboard, 60000);
</script> </script>
</body> </body>
</html> </html>
+9 -2
View File
@@ -1,22 +1,29 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.main import create_app from app.main import create_app
def test_health_endpoint_returns_app_identity() -> None: def test_health_endpoint_returns_app_identity(monkeypatch) -> None:
# 健康检查只暴露服务身份,不触发Agent运行或数据库写入。 # 健康检查只暴露服务身份,不触发Agent运行或数据库写入。
monkeypatch.setenv("SIGNALSCOUT_ENV", "test")
get_settings.cache_clear()
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
response = client.get("/health") response = client.get("/health")
get_settings.cache_clear()
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "ok" assert response.json()["status"] == "ok"
assert response.json()["app"] == "SignalScout" assert response.json()["app"] == "SignalScout"
def test_home_page_returns_dashboard_shell() -> None: def test_home_page_returns_dashboard_shell(monkeypatch) -> None:
# 根路径应该展示业务工作台,而不是返回接口404。 # 根路径应该展示业务工作台,而不是返回接口404。
monkeypatch.setenv("SIGNALSCOUT_ENV", "test")
get_settings.cache_clear()
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
response = client.get("/") response = client.get("/")
get_settings.cache_clear()
assert response.status_code == 200 assert response.status_code == 200
assert "SignalScout" in response.text assert "SignalScout" in response.text
+12
View File
@@ -0,0 +1,12 @@
from app.agent.scheduler import build_scheduler
from app.core.config import Settings
def test_scheduler_runs_on_interval() -> None:
# Agent默认按固定间隔自动运行,避免产品依赖用户手动触发。
scheduler = build_scheduler(Settings(agent_interval_minutes=30))
job = scheduler.get_job("continuous-intelligence")
assert job is not None
assert job.trigger.interval.total_seconds() == 1800
assert job.max_instances == 1