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

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