v1.3.0: 每源独立采集周期 + 定制监控与新闻监控分离(独立配置/独立汇总) + 历史采样留档与提取API

This commit is contained in:
2026-08-29 19:29:28 +08:00
parent 2cad7a2976
commit de08530958
12 changed files with 489 additions and 88 deletions
+17 -7
View File
@@ -2,7 +2,7 @@
一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。
> 当前版本 **v1.2.0**:数据源**完整编辑**(所有配置可改,模态框)+ **定制监控类型**(无权重,按「推送标准」由大模型判断是否推送)
> 当前版本 **v1.3.0**:数据源**独立采集周期**(每源可覆盖全局)+ **定制监控与新闻监控完全分离**(独立间隔/独立汇总)+ **历史采样留档与提取**
---
@@ -29,7 +29,9 @@
2. **规则打分**(立即):`analysis.analyze_article()` 对每条新资讯算**兴趣相关度**(关键词命中权重 + 领域匹配 + 关注公司命中)和**重要度启发式**(发布/融资/禁令等强信号词、金额量级、时效性、数据源权重、公司影响),得到**综合分 total_score (0-100)**。
3. **LLM 深度分析**(后台异步):规则分 ≥ `llm_threshold`(默认 60)的资讯进入 `llm_analyze()`,**使用网页激活的大模型接口**(默认 SiliconFlow,可一键切换;调用失败自动切换下一个可用接口),输出重要度 1-10、相关度、分类、一句话结论,并与规则分融合更新。
4. **实时通知**:扫描「重要 + 已分析完成 + 未通知」的资讯,综合分 ≥ `realtime_threshold`(默认 80)→ 发 **🔥 重要AI资讯实时提醒** 邮件,标记 `notified=1`
5. **每日日报**(默认每天 10:00):汇总往前 `summary_window_hours`(默认 24h)内综合分 ≥50 的资讯,按分排序取前 `max_summary_items`(默认 15)条发 **📰 AI资讯日报**,含领域分布统计。
5. **每日日报**新闻机制,默认每天 10:00):汇总往前 `summary_window_hours`(默认 24h)内普通源综合分 ≥50 的资讯,按分排序取前 `max_summary_items`(默认 15)条发 **📰 AI资讯日报**,含领域分布统计。
6. **定制监控汇总**(定制机制,默认每天 18:00,独立时间单独配置):汇总窗口内定制源**命中推送标准**(is_important=1)的资讯发 **🎯 定制监控汇总** 邮件。
7. **历史采样**:每次采集(无论成功/失败)写入 `source_snapshots`,数据源页可查看,也可用 `/api/sources/history``/api/sources/articles` 提取给自动流程。
---
@@ -40,6 +42,9 @@
- 🤖 **大模型接口可配置**:设置页可增删改/测试大模型接口,预置 SiliconFlow(默认)/ DeepSeek 官方 / Autodl / Local Qwen 四个,**一键切换即时生效**,激活接口失败自动切换下一个可用接口
- 🔥 **实时重要资讯邮件**:综合分达到阈值自动推送,单批最多 10 条
- 🎯 **定制监控数据源**:数据源可选「定制监控」方式——**不设权重**,填写「推送标准」后由大模型逐条判断是否达到标准,达到即实时邮件推送(页面可直接「🧪 测试推送标准」);命中推送的资讯在邮件/列表中带「🎯 定制监控命中」标识
- 🔀 **两套独立机制**:新闻监控与定制监控**完全分开**——各自的采集间隔、汇总时间/窗口/条数单独配置(设置页「定制监控机制」区块);定制源命中会单独发「🎯 定制监控汇总」邮件,不混入新闻日报
-**每源独立采集周期**:全局有统一采集间隔(新闻30分/定制15分),每个数据源可用本源「采集周期」字段自定义覆盖(0=跟随全局)
- 📜 **历史采样留档**:每次采集记录时间/条数/成功失败到 `source_snapshots`,数据源页「📜 历史采样」可查看每次快照及本源采集到的资讯;`/api/sources/history``/api/sources/articles` 供自动流程提取历史数据
- 📰 **每日 AI 资讯日报**:默认每天 10:00,按领域统计 + 重点资讯卡片
- 🎯 **兴趣画像管理**:关键词(带权重)、领域、关注公司,全部可在网页维护,改完一键「重新打分」
- 🔗 **数据源管理**:7 个内置数据源模板,**完整编辑**(模态框一次性改名称/类型/URL/权重/描述/监控方式/推送标准)、启停、删除;普通源带权重,定制监控源填「推送标准」
@@ -145,10 +150,10 @@ cd works/news-tracker
| `/dashboard` | 仪表盘:总资讯/重要资讯/待分析/已通知 + 近7天趋势 + 领域分布 |
| `/news` | 资讯列表:按领域/重要/关键词搜索,分页 |
| `/news/<id>` | 资讯详情:规则分/LLM分/实体/分析结论 + **页面可读全文** |
| `/sources` | 数据源管理:**完整编辑**(模态框)/启停/删除,支持普通(权重)与定制监控(推送标准),真实源状态可见 |
| `/sources` | 数据源管理:**完整编辑**(模态框)/启停/删除,支持普通(权重)与定制监控(推送标准),每源独立采集周期,「📜 历史采样」可查看每次采样快照与本源资讯 |
| `/profile` | 兴趣画像:关键词/领域/公司维护 |
| `/logs` | 通知日志(实时/日报发送记录) |
| `/settings` | 设置:自动化参数 + 邮件配置 + **大模型接口管理(一键切换/测试)** |
| `/settings` | 设置:自动化参数(新闻机制)+ **定制监控机制(独立配置)** + 邮件配置 + **大模型接口管理(一键切换/测试)** |
---
@@ -157,7 +162,9 @@ cd works/news-tracker
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/stats` | GET | 统计数据(总数/重要/趋势/领域分布) |
| `/api/sources` | POST | `action=add\|update\|delete\|toggle\|test_standard` 数据源管理(update 可改全部配置;test_standard=用大模型试测推送标准) |
| `/api/sources` | POST | `action=add\|update\|delete\|toggle\|test_standard` 数据源管理(update 可改全部配置含采集周期test_standard=用大模型试测推送标准) |
| `/api/sources/history` | GET | `?source_id=&limit=` 数据源历史采样记录(每次采集时间/条数/状态/备注,自动流程提取用) |
| `/api/sources/articles` | GET | `?source_id=&page=&page_size=&q=` 数据源历史采集到的资讯(分页/搜索,自动流程提取用) |
| `/api/profile` | POST | `action=add\|delete``kind=keyword\|domain\|company` 画像维护 |
| `/api/settings` | POST | 更新 `auto` / `mail` 设置 |
| `/api/llm` | POST | 大模型接口:`add\|update\|delete\|switch\|toggle\|test`switch=一键切换) |
@@ -169,7 +176,8 @@ cd works/news-tracker
## 数据模型
- **sources**:数据源(name/type/url/weight/**kind**/**monitor_standard**/enabled/status/last_fetch/last_count`kind`=normal 普通按权重打分 / custom 定制监控按推送标准 LLM 判断;`monitor_standard`=定制监控的推送标准说明;status 标记真实源采集 ok/error
- **sources**:数据源(name/type/url/weight/**kind**/**monitor_standard**/**scan_interval_min**/enabled/status/last_fetch/last_count`kind`=normal 普通按权重打分 / custom 定制监控按推送标准 LLM 判断;`monitor_standard`=定制监控的推送标准说明;`scan_interval_min`=本源采集间隔分钟数,0=跟随所属机制全局值;status 标记真实源采集 ok/error
- **source_snapshots**:历史采样记录(source_id/fetched_at/count/status/detail,每次采集留档)
- **articles**:资讯(title/url/content/summary/**full_text(页面可读全文)**/domain/entities/importance/relevance/total_score/is_important/analysis/llm_status/notified/status/published_at/collected_at
- **keywords** / **domains** / **companies**:兴趣画像
- **llm_providers**:大模型接口(name/base_url/api_key/model/active/enabledactive=当前激活)
@@ -178,6 +186,8 @@ cd works/news-tracker
`llm_status``pending`(待分析) → `done`(完成) / `skipped`(未达标跳过) / `error`(失败)
**采集调度**`scheduler.collect_once()` 按**每源独立周期**采集(本源 `scan_interval_min` > 0 用本源值,否则普通源用 `scan_interval_min`、定制源用 `custom_scan_interval_min`);调度循环 30s 轮询到期源。新闻源受 `auto_collect` 控制、定制源受 `custom_enabled` 控制,互不影响。手动「立即采集」忽略周期全量采集。
**定制监控(kind=custom**:无权重(规则打分时权重项按 0 处理,仅作展示);采集后**无条件进入 LLM 分析**(不受 `llm_threshold` 限制),LLM 按该源的 `monitor_standard`(推送标准)判断 `meets_standard`,达到 → `is_important=1` → 实时邮件推送;未达到 → 不推送。命中推送的资讯也会纳入每日日报。
**综合分公式**`total_score = 0.4 × 相关度 + 0.6 × 重要度`LLM 分析后按 `0.6×规则分 + 0.4×LLM分量` 融合)
@@ -211,4 +221,4 @@ cd works/news-tracker
## Git
- 仓库:`hz4th_coder/news-tracker`
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ `v1.1.0`(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)→ **`v1.2.0`**(数据源完整编辑模态框 / 定制监控类型:无权重+推送标准+LLM判断推送+测试按钮)
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ `v1.1.0`(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)→ `v1.2.0`(数据源完整编辑模态框 / 定制监控类型:无权重+推送标准+LLM判断推送+测试按钮)**`v1.3.0`**(每源独立采集周期 / 定制监控与新闻监控分离独立配置+独立汇总 / 历史采样留档+查看+提取API)
+71 -3
View File
@@ -117,7 +117,8 @@ def api_sources():
sid = db.add_source(data.get("name", ""), data.get("type", ""), data.get("url", ""),
data.get("description", ""), float(data.get("weight", 1.0)),
kind=data.get("kind", "normal"),
monitor_standard=data.get("monitor_standard", ""))
monitor_standard=data.get("monitor_standard", ""),
scan_interval_min=int(data.get("scan_interval_min", 0) or 0))
return jsonify({"ok": True, "id": sid})
if action == "update":
db.update_source(data["id"], name=data.get("name"), type=data.get("type"),
@@ -125,6 +126,7 @@ def api_sources():
weight=float(data.get("weight", 1.0)),
kind=data.get("kind", "normal"),
monitor_standard=data.get("monitor_standard", ""),
scan_interval_min=int(data.get("scan_interval_min", 0) or 0),
enabled=1 if data.get("enabled") else 0)
return jsonify({"ok": True})
if action == "delete":
@@ -158,6 +160,60 @@ def api_sources():
return jsonify({"ok": False, "error": "unknown action"})
@app.route("/api/sources/history")
def api_source_history():
"""数据源历史采样记录(供页面查看 / 自动流程提取)
GET /api/sources/history?source_id=1&limit=50
返回: {source: {...}, snapshots: [{id,fetched_at,count,status,detail}], total, limit}
"""
source_id = request.args.get("source_id", type=int)
if not source_id:
return jsonify({"ok": False, "error": "source_id 必填"})
s = db.get_source(source_id)
if not s:
return jsonify({"ok": False, "error": "数据源不存在"})
limit = min(500, request.args.get("limit", 50, type=int))
snaps = db.list_source_snapshots(source_id, limit=limit)
return jsonify({"ok": True, "source": s, "snapshots": snaps, "total": len(snaps), "limit": limit})
@app.route("/api/sources/articles")
def api_source_articles():
"""数据源历史采集到的资讯(自动流程提取用)
GET /api/sources/articles?source_id=1&page=1&page_size=20&q=关键词
返回: {ok, source, articles: [...], page, page_size, total}
"""
source_id = request.args.get("source_id", type=int)
if not source_id:
return jsonify({"ok": False, "error": "source_id 必填"})
s = db.get_source(source_id)
if not s:
return jsonify({"ok": False, "error": "数据源不存在"})
page = max(1, request.args.get("page", 1, type=int))
page_size = min(100, max(1, request.args.get("page_size", 20, type=int)))
q = request.args.get("q", "")
conn = db.get_conn()
where, args = "a.source_id=?", [source_id]
if q:
where += " AND (a.title LIKE ? OR a.content LIKE ? OR a.summary LIKE ?)"
args += [f"%{q}%", f"%{q}%", f"%{q}%"]
total = conn.execute(f"SELECT COUNT(*) c FROM articles a WHERE {where}", args).fetchone()["c"]
rows = conn.execute(
f"SELECT a.* FROM articles a WHERE {where} ORDER BY a.collected_at DESC, a.id DESC "
f"LIMIT ? OFFSET ?", args + [page_size, (page - 1) * page_size]).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
try:
d["entities"] = json.loads(d["entities"] or "[]")
except Exception:
d["entities"] = []
out.append(d)
return jsonify({"ok": True, "source": s, "articles": out, "page": page,
"page_size": page_size, "total": total})
@app.route("/api/profile", methods=["POST"])
def api_profile():
data = request.get_json(force=True) or {}
@@ -197,6 +253,10 @@ def api_settings():
cur = db.get_all_settings().get("mail", {})
cur.update(data["mail"])
db.set_setting("mail", cur)
if "custom" in data and isinstance(data["custom"], dict):
cur = db.get_all_settings().get("custom", {})
cur.update(data["custom"])
db.set_setting("custom", cur)
return jsonify({"ok": True})
@@ -268,7 +328,7 @@ def api_actions():
data = request.get_json(force=True) or {}
action = data.get("action")
if action == "collect":
n = scheduler.collect_once()
n = scheduler.collect_once(force=True)
return jsonify({"ok": True, "added": n})
if action == "llm":
r = analysis.batch_llm_analyze(limit=int(data.get("limit", 10)))
@@ -276,6 +336,9 @@ def api_actions():
if action == "summary":
n = scheduler.send_daily_summary()
return jsonify({"ok": True, "sent": n})
if action == "custom_summary":
n = scheduler.send_custom_summary()
return jsonify({"ok": True, "sent": n})
if action == "seed":
n = simulate.seed_all()
return jsonify({"ok": True, "added": n})
@@ -306,12 +369,16 @@ def not_found(e):
# ---------------- 启动 ----------------
def main():
db.init_db()
# 确保定制监控机制配置存在(老库升级)
if db.get_setting("custom") is None:
db.set_setting("custom", dict(config.CUSTOM_DEFAULTS))
# 首次初始化:写入默认数据源 / 兴趣画像 / 默认设置 / 模拟数据
if db.get_setting("initialized") != 1:
for s in config.DEFAULT_SOURCES:
db.add_source(s["name"], s["type"], s["url"], s["description"], s["weight"],
kind=s.get("kind", "normal"),
monitor_standard=s.get("monitor_standard", ""))
monitor_standard=s.get("monitor_standard", ""),
scan_interval_min=s.get("scan_interval_min", 0))
for kw, w in config.DEFAULT_KEYWORDS:
db.add_keyword(kw, w)
for d, w in config.DEFAULT_DOMAINS:
@@ -327,6 +394,7 @@ def main():
db.set_active_provider(pid)
db.set_setting("initialized", 1)
db.set_setting("auto", dict(config.AUTO_DEFAULTS))
db.set_setting("custom", dict(config.CUSTOM_DEFAULTS))
db.set_setting("mail", dict(config.MAIL_DEFAULTS))
simulate.seed_all()
analysis.run_llm_background()
+12 -2
View File
@@ -67,8 +67,8 @@ MAIL_DEFAULTS = {
# ---------------- 自动化(默认值,可在设置区修改) ----------------
AUTO_DEFAULTS = {
"auto_collect": 1, # 是否自动定时采集
"scan_interval_min": 30, # 采集扫描间隔(分钟)
"auto_collect": 1, # 是否自动定时采集(新闻监控机制)
"scan_interval_min": 30, # 采集扫描间隔(分钟,新闻源统一值,每源可覆盖
"realtime_threshold": 80, # 总分 >= 该值 → 实时邮件通知
"llm_threshold": 60, # 规则分 >= 该值 → 进入 LLM 深度分析
"realtime_enabled": 1, # 是否启用实时重要资讯邮件
@@ -78,6 +78,16 @@ AUTO_DEFAULTS = {
"max_summary_items": 15, # 汇总邮件最多条目数
}
# ---------------- 定制监控机制(与新闻监控分开单独配置) ----------------
CUSTOM_DEFAULTS = {
"custom_enabled": 1, # 是否启用定制监控机制(采集/判断/推送/汇总)
"custom_scan_interval_min": 15, # 定制源统一采集间隔(分钟,每源可覆盖;比新闻更频繁)
"custom_summary_enabled": 1, # 是否单独发「定制监控汇总」邮件
"custom_summary_time": "18:00", # 定制监控汇总时间(独立于新闻日报)
"custom_summary_window_hours": 24,
"custom_max_summary_items": 20, # 定制汇总邮件最多条目数
}
# ---------------- 默认兴趣画像(可在网页修改) ----------------
DEFAULT_KEYWORDS = [
("大模型", 8), ("人工智能", 6), ("AI", 6), ("芯片", 7), ("GPU", 6),
+5 -2
View File
@@ -232,9 +232,12 @@ def fetch_source(source):
example.com 占位源返回模拟数据;真实源抓取失败抛异常(由 fetch_all 捕获并标记 error,不塞模拟数据)。
"""
url = (source.get("url") or "").strip()
# 模拟源(example.com 占位)→ 用仿真数据填充
# 模拟源(example.com 占位)→ 用仿真数据填充(补齐 source_id,保证定制监控识别正确)
if not url or "example.com" in url:
return simulate.items_for_source(source["id"], source.get("type", ""))
items = simulate.items_for_source(source["id"], source.get("type", ""))
for it in items:
it["source_id"] = source["id"]
return items
per = config.CRAWL_DEFAULTS["per_source_links"]
full = config.CRAWL_DEFAULTS["full_fetch_links"]
html = fetch_page(url)
+80 -11
View File
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS sources (
weight REAL DEFAULT 1.0,
kind TEXT DEFAULT 'normal', -- normal 普通(按权重打分) / custom 定制监控(按推送标准LLM判断)
monitor_standard TEXT DEFAULT '', -- 定制监控:推送标准说明
scan_interval_min INTEGER DEFAULT 0, -- 本源采集间隔(分钟),0=跟随所属机制的全局值
enabled INTEGER DEFAULT 1,
status TEXT DEFAULT 'ok', -- ok / error
last_fetch TEXT DEFAULT '',
@@ -27,6 +28,17 @@ CREATE TABLE IF NOT EXISTS sources (
created_at TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS source_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
fetched_at TEXT DEFAULT '', -- 本次采样时间
count INTEGER DEFAULT 0, -- 本次采到条数
status TEXT DEFAULT 'ok', -- ok / error
detail TEXT DEFAULT '', -- 错误信息 / 备注
created_at TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_snapshots_source ON source_snapshots(source_id, id);
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER DEFAULT 0,
@@ -113,12 +125,25 @@ def init_db():
os.makedirs(config.DATA_DIR, exist_ok=True)
conn = get_conn()
conn.executescript(_SCHEMA)
# 迁移:旧库补充 sources.kind / monitor_standard(定制监控类型)
# 迁移:旧库补充 sources.kind / monitor_standard / scan_interval_min
cols = [r["name"] for r in conn.execute("PRAGMA table_info(sources)").fetchall()]
if "kind" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN kind TEXT DEFAULT 'normal'")
if "monitor_standard" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN monitor_standard TEXT DEFAULT ''")
if "scan_interval_min" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN scan_interval_min INTEGER DEFAULT 0")
# 迁移:历史采样表(每次采集留档)
conn.execute("""CREATE TABLE IF NOT EXISTS source_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
fetched_at TEXT DEFAULT '',
count INTEGER DEFAULT 0,
status TEXT DEFAULT 'ok',
detail TEXT DEFAULT '',
created_at TEXT DEFAULT ''
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_snapshots_source ON source_snapshots(source_id, id)")
# 迁移:旧库补充 full_text 列(存页面可读正文)
cols = [r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()]
if "full_text" not in cols:
@@ -187,12 +212,12 @@ def get_source(sid):
return dict(row) if row else None
def add_source(name, type_, url, desc, weight=1.0, kind="normal", monitor_standard=""):
def add_source(name, type_, url, desc, weight=1.0, kind="normal", monitor_standard="", scan_interval_min=0):
conn = get_conn()
cur = conn.execute(
"INSERT INTO sources(name,type,url,description,weight,kind,monitor_standard,created_at) "
"VALUES(?,?,?,?,?,?,?,?)",
(name, type_, url, desc, weight, kind, monitor_standard, now_str()),
"INSERT INTO sources(name,type,url,description,weight,kind,monitor_standard,scan_interval_min,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?)",
(name, type_, url, desc, weight, kind, monitor_standard, scan_interval_min, now_str()),
)
conn.commit()
conn.close()
@@ -200,7 +225,8 @@ def add_source(name, type_, url, desc, weight=1.0, kind="normal", monitor_standa
def update_source(sid, **fields):
allowed = {"name", "type", "url", "description", "weight", "kind", "monitor_standard", "enabled"}
allowed = {"name", "type", "url", "description", "weight", "kind", "monitor_standard",
"scan_interval_min", "enabled"}
sets, vals = [], []
for k, v in fields.items():
if k in allowed:
@@ -232,6 +258,28 @@ def update_source_fetch(sid, status="ok", count=0):
conn.close()
# ---------------- 历史采样(每次采集留档,可查看/自动流程提取) ----------------
def add_source_snapshot(source_id, count=0, status="ok", detail=""):
conn = get_conn()
conn.execute(
"INSERT INTO source_snapshots(source_id,fetched_at,count,status,detail,created_at) "
"VALUES(?,?,?,?,?,?)",
(source_id, now_str(), count, status, detail, now_str()),
)
conn.commit()
conn.close()
def list_source_snapshots(source_id, limit=50):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM source_snapshots WHERE source_id=? ORDER BY id DESC LIMIT ?",
(source_id, limit),
).fetchall()
conn.close()
return [dict(r) for r in rows]
# ---------------- articles ----------------
def article_exists(url):
conn = get_conn()
@@ -370,15 +418,36 @@ def article_stats():
def latest_articles_for_summary(window_hours=24):
"""汇总窗口内(含未通知、未汇总的)资讯
普通源按 total_score>=50;定制监控命中推送标准(is_important=1)的也纳入,不看分数。"""
"""新闻机制汇总:普通源(normal),窗口内 total_score>=50"""
conn = get_conn()
rows = conn.execute(
"SELECT a.*, s.name AS source_name FROM articles a "
"SELECT a.*, s.name AS source_name, s.kind AS source_kind FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id "
"WHERE a.published_at >= datetime('now','localtime','-%d hours') "
"AND (a.total_score>=50 OR (s.kind='custom' AND a.is_important=1)) "
"ORDER BY a.total_score DESC LIMIT 200" % int(window_hours)
"AND (s.kind IS NULL OR s.kind != 'custom') "
"AND a.total_score>=50 ORDER BY a.total_score DESC LIMIT 200" % int(window_hours)
).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
try:
d["entities"] = json.loads(d["entities"] or "[]")
except Exception:
d["entities"] = []
out.append(d)
return out
def custom_articles_for_summary(window_hours=24):
"""定制监控汇总:定制源(custom),窗口内命中推送标准(is_important=1)的资讯"""
conn = get_conn()
rows = conn.execute(
"SELECT a.*, s.name AS source_name, s.kind AS source_kind FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id "
"WHERE a.published_at >= datetime('now','localtime','-%d hours') "
"AND s.kind='custom' AND a.is_important=1 "
"ORDER BY a.published_at DESC, a.id DESC LIMIT 200" % int(window_hours)
).fetchall()
conn.close()
out = []
+23 -1
View File
@@ -106,7 +106,7 @@ def send_realtime(articles):
def send_daily_summary(articles, window_label):
"""每日汇总(默认每天10点):昨天至今的重要/相关资讯"""
"""新闻机制:每日汇总(默认每天10点)——普通源的重要/相关资讯"""
if not articles:
return 0
top = articles[: int(db.get_setting("max_summary_items", config.AUTO_DEFAULTS["max_summary_items"]))]
@@ -133,3 +133,25 @@ def send_daily_summary(articles, window_label):
if art and art.get("status") != "summarized":
db.update_article(aid, status="summarized")
return len(top)
def send_custom_summary(articles, window_label):
"""定制监控机制:独立汇总邮件——定制源在窗口内命中推送标准的资讯(单独配置/单独时间)"""
if not articles:
return 0
top = articles[: int(db.get_setting("custom_max_summary_items",
config.CUSTOM_DEFAULTS["custom_max_summary_items"]))]
cards = "".join(_card(a) for a in top)
subject = f"🎯 定制监控汇总 {window_label} · 命中{len(articles)}"
html = _html_wrap(
"定制监控命中汇总",
f"""
<p style="color:#374151;font-size:13px;">汇总时段:{window_label}</p>
<p style="color:#6b7280;font-size:12px;">以下为定制监控源中<b>达到推送标准</b>的资讯(由大模型按各源推送标准判定):</p>
{cards}
""",
)
send_email(subject, html)
ids = [a["id"] for a in articles if a["id"]]
db.add_log("custom_summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}")
return len(top)
+123 -54
View File
@@ -1,8 +1,13 @@
# -*- coding: utf-8 -*-
"""
新闻智能跟踪系统 - 后台调度器
1. 定时采集(scan_interval_min 分钟一次):拉取 → 入库 → 规则分析 → 后台LLM深度分析 → 实时通知
2. 每日汇总(默认 10:00):汇总昨天至今重要资讯邮件
两套独立机制(互不混用,各自单独配置):
1. 新闻监控机制(普通源 normal):统一采集间隔 scan_interval_min + 每日新闻日报 + 实时重要资讯
2. 定制监控机制(定制源 custom):统一采集间隔 custom_scan_interval_min + 定制监控汇总 + 命中实时推送
每个数据源都可用 scan_interval_min 覆盖所属机制的全局采集间隔(0=跟随全局)。
每次采集都会写入 source_snapshots 历史采样表,供查看与自动流程提取。
"""
import threading
import time
@@ -10,23 +15,66 @@ from datetime import datetime, timedelta
import config
import db
import simulate
import simulate # noqa: F401 (保留引用,crawler 内部使用)
import crawler
import analysis
import notifier
def collect_once():
"""执行一次采集全流程,返回新增数
真实 URL 源走网页抓取+正文清洗+全文入库;模拟源(example.com)回退仿真数据。
def source_interval(s):
"""单个数据源的实际采集间隔(分钟):优先本源自定义值,否则跟随所属机制全局值"""
iv = int(s.get("scan_interval_min") or 0)
if iv > 0:
return iv
if s.get("kind") == "custom":
return max(1, int(db.get_setting("custom_scan_interval_min",
config.CUSTOM_DEFAULTS["custom_scan_interval_min"])))
return max(1, int(db.get_setting("scan_interval_min", config.AUTO_DEFAULTS["scan_interval_min"])))
def _is_due(s):
"""判断该源是否到点需要采集"""
interval = source_interval(s)
last = (s.get("last_fetch") or "").strip()
if not last:
return True
try:
lt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
return (datetime.now() - lt).total_seconds() / 60 >= interval
except Exception:
return True
def collect_once(force=False):
"""采集到期的数据源,返回新增条数。
force=True:忽略周期,全部采集(用于手动「立即采集」)。
新闻源受 auto_collect 控制,定制源受 custom_enabled 控制,两套独立。
"""
if not int(db.get_setting("auto_collect", config.AUTO_DEFAULTS["auto_collect"])):
auto = int(db.get_setting("auto_collect", config.AUTO_DEFAULTS["auto_collect"]))
custom = int(db.get_setting("custom_enabled", config.CUSTOM_DEFAULTS["custom_enabled"]))
if not auto and not custom:
return 0
sources = db.list_sources(only_enabled=True)
if not sources:
return 0
items, per_source = crawler.fetch_all()
added = 0
items, added = [], 0
for s in sources:
if s.get("kind") == "custom":
if not custom:
continue
else:
if not auto:
continue
if not force and not _is_due(s):
continue
try:
got = crawler.fetch_source(s)
db.update_source_fetch(s["id"], status="ok", count=len(got))
db.add_source_snapshot(s["id"], len(got), "ok", "")
items.extend(got)
except Exception as e:
db.update_source_fetch(s["id"], status="error", count=0)
db.add_source_snapshot(s["id"], 0, "error", str(e)[:300])
for it in items:
if db.article_exists(it["url"]):
continue
@@ -35,7 +83,7 @@ def collect_once():
added += 1
# 后台 LLM 深度分析
analysis.run_llm_background()
# 实时通知
# 实时通知(新闻重要资讯 + 定制监控命中)
try:
send_realtime_if_needed()
except Exception:
@@ -44,21 +92,33 @@ def collect_once():
def send_realtime_if_needed():
"""扫描已分析完成、重要、未通知的资讯发实时邮件"""
if not int(db.get_setting("realtime_enabled", config.AUTO_DEFAULTS["realtime_enabled"])):
return 0
# LLM 深度分析完成后,重新判定重要度并通知
arts = db.list_articles(is_important=1, order="a.total_score DESC", limit=20)
pending = [a for a in arts if not a["notified"] and a["llm_status"] != "pending"]
"""扫描已分析完成、重要、未通知的资讯发实时邮件
普通源受 realtime_enabled 控制;定制源受 custom_enabled 控制。"""
auto_rt = int(db.get_setting("realtime_enabled", config.AUTO_DEFAULTS["realtime_enabled"]))
custom_on = int(db.get_setting("custom_enabled", config.CUSTOM_DEFAULTS["custom_enabled"]))
arts = db.list_articles(is_important=1, order="a.total_score DESC", limit=30)
pending = []
for a in arts:
if a["notified"] or a["llm_status"] == "pending":
continue
if a.get("source_id"):
src = db.get_source(a["source_id"])
if src and src.get("kind") == "custom":
if custom_on:
pending.append(a)
else:
if auto_rt:
pending.append(a)
elif auto_rt:
pending.append(a)
if not pending:
return 0
# 批量发(控制每封数量)
batch = pending[:10]
return notifier.send_realtime(batch)
def send_daily_summary():
"""每日汇总:昨天至今的重要/相关资讯"""
"""新闻机制:每日日报(普通源),默认每天 10:00"""
if not int(db.get_setting("summary_enabled", config.AUTO_DEFAULTS["summary_enabled"])):
return 0
window = int(db.get_setting("summary_window_hours", config.AUTO_DEFAULTS["summary_window_hours"]))
@@ -71,56 +131,65 @@ def send_daily_summary():
return notifier.send_daily_summary(articles, label)
def _next_summary_run():
"""计算下一次汇总时间点(默认每天 10:00,可配置"""
hm = str(db.get_setting("summary_time", config.AUTO_DEFAULTS["summary_time"]))
def send_custom_summary():
"""定制机制:定制监控汇总(定制源命中),独立时间单独配置"""
if not int(db.get_setting("custom_summary_enabled", config.CUSTOM_DEFAULTS["custom_summary_enabled"])):
return 0
window = int(db.get_setting("custom_summary_window_hours",
config.CUSTOM_DEFAULTS["custom_summary_window_hours"]))
articles = db.custom_articles_for_summary(window)
if not articles:
return 0
start = (datetime.now() - timedelta(hours=window)).strftime("%m-%d %H:%M")
end = datetime.now().strftime("%m-%d %H:%M")
label = f"{start} ~ {end}"
return notifier.send_custom_summary(articles, label)
def _maybe_daily(now, last_day, time_key, send_fn, kind):
"""到点触发的通用逻辑。time_key 存 'HH:MM'send_fn 返回发送条数。"""
day_key = now.strftime("%Y-%m-%d")
if last_day[0] == day_key:
return last_day
hm = str(db.get_setting(time_key, config.AUTO_DEFAULTS.get(time_key) or
config.CUSTOM_DEFAULTS.get(time_key) or "10:00"))
try:
hh, mm = hm.split(":")
hh, mm = int(hh), int(mm)
hh = int(hm.split(":")[0])
except Exception:
hh, mm = 10, 0
now = datetime.now()
nxt = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if nxt <= now:
nxt = nxt + timedelta(days=1)
return nxt
hh = 0
if now.strftime("%H:%M") >= hm and now.hour >= hh:
try:
send_fn()
last_day = (day_key,)
except Exception as e:
db.add_log(kind, f"{time_key} 汇总异常", 0, [], status="error", detail=str(e))
return last_day
def scheduler_loop(stop_event):
last_summary_day = None
last_custom_summary_day = None
while not stop_event.is_set():
now = datetime.now()
try:
# 每日汇总
now = datetime.now()
day_key = now.strftime("%Y-%m-%d")
if last_summary_day != day_key:
hm = str(db.get_setting("summary_time", config.AUTO_DEFAULTS["summary_time"]))[:5]
if now.strftime("%H:%M") >= hm and now.hour >= int(hm.split(":")[0]):
try:
send_daily_summary()
last_summary_day = day_key
except Exception as e:
db.add_log("summary", "每日汇总异常", 0, [], status="error", detail=str(e))
last_summary_day = _maybe_daily(now, (last_summary_day,), "summary_time",
send_daily_summary, "summary")
last_custom_summary_day = _maybe_daily(now, (last_custom_summary_day,), "custom_summary_time",
send_custom_summary, "custom_summary")
except Exception:
pass
# 定时采集(以分钟为单位)
interval = int(db.get_setting("scan_interval_min", config.AUTO_DEFAULTS["scan_interval_min"]))
next_scan = time.time() + interval * 60
# 在等待期间兼顾实时通知(LLM 分析完成后推送)
while time.time() < next_scan and not stop_event.is_set():
try:
send_realtime_if_needed()
except Exception:
pass
stop_event.wait(min(30, max(5, interval * 60)))
if stop_event.is_set():
break
# 采集到期数据源(每源独立周期)
try:
collect_once()
except Exception as e:
db.add_log("realtime", "采集异常", 0, [], status="error", detail=str(e))
# 实时通知(LLM 分析完成后推送)
try:
send_realtime_if_needed()
except Exception:
pass
# 30s 轮询粒度,兼顾每源自定义的短周期(如 5 分钟)
stop_event.wait(30)
return
+28 -1
View File
@@ -202,12 +202,14 @@ def _make_item(row):
"summary": summary,
"domain": domain,
"entities": entities,
"source_id": int(src_idx),
"published_at": published.strftime("%Y-%m-%d %H:%M:%S"),
}
def items_for_source(src_id, src_type=""):
"""返回指定数据源的模拟条目(真实抓取失败/模拟源时回退用)"""
"""返回指定数据源的模拟条目(真实抓取失败/模拟源时回退用)
无内建匹配条目的源(如新建的定制监控源)→ 合成几条通用资讯,保证链路不空转。"""
items = []
for row in _NEWS:
if row[3] == int(src_id):
@@ -215,6 +217,31 @@ def items_for_source(src_id, src_type=""):
if src_type:
it["domain"] = src_type
items.append(it)
if not items:
s = db.get_source(int(src_id)) if db else None
name = (s.get("name") if s else "") or f"数据源{src_id}"
generic = [
("新品发布与里程碑", "该公司/该源今日宣布一项重要产品发布与里程碑进展,引发行业广泛关注,多位分析师上调预期。",
"重大产品发布,行业里程碑。"),
("重大战略合作", "该公司今日宣布与多家头部企业达成重大战略合作,涉及核心业务扩张与生态布局,市场反应积极。",
"重大战略合作公告。"),
("关键进展与新动态", "该领域出现关键性进展与新动态,相关技术/产品进入新阶段,预计将显著影响后续走向。",
"关键进展,影响显著。"),
("股价大幅波动", "受重大消息影响,该相关标的股价今日大幅波动,单日涨跌幅显著,市场情绪剧烈变化。",
"股价大幅波动。"),
("日常行业动态", "该领域今日整体平稳,行业例行消息与常规动态为主,无实质性重大变化。", "日常动态。"),
]
import hashlib
from datetime import timedelta
for i, (t, c, sm) in enumerate(generic):
published = datetime.now() - timedelta(hours=2 + i)
url = "https://news.example.com/s/" + hashlib.md5(f"{src_id}-{t}".encode()).hexdigest()[:16]
items.append({
"title": t, "url": url, "author": name,
"content": c, "summary": sm, "domain": src_type or "综合",
"entities": [], "source_id": int(src_id),
"published_at": published.strftime("%Y-%m-%d %H:%M:%S"),
})
return items
+3
View File
@@ -123,6 +123,9 @@ textarea { width:100%; }
.modal-body textarea { resize:vertical; }
.modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 20px; border-top:1px solid #f3f4f6; }
.modal-mask[hidden] { display:none !important; }
.hist-list { max-height:300px; overflow:auto; }
.hist-item { padding:7px 0; border-bottom:1px solid #f9fafb; }
.hist-item:last-child { border-bottom:none; }
/* 页面可读全文(干净排版) */
.full-text { margin-top:8px; padding:14px 16px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px; font-size:13px; line-height:1.9; color:#374151; white-space:pre-wrap; word-break:break-word; }
+9
View File
@@ -38,6 +38,15 @@ const API = {
body: JSON.stringify(body)
});
return r.json();
},
get: async (url, params) => {
if (params){
const qs = Object.entries(params).filter(([k,v])=>v!==''&&v!=null)
.map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&');
if (qs) url += (url.includes('?')?'&':'?') + qs;
}
const r = await fetch(url);
return r.json();
}
};
function toast(msg, ok=true) {
+39 -1
View File
@@ -67,6 +67,36 @@
</section>
</div>
<section class="card">
<h3>🎯 定制监控机制
<span class="muted small">(与新闻监控完全独立:定制源按此间隔采集,命中推送标准单独汇总;每源可用本源「采集周期」覆盖间隔)</span>
</h3>
<div class="setting-row">
<label>启用定制监控机制(采集+LLM判断+命中推送)</label>
<input type="checkbox" id="c_custom_enabled" {{ 'checked' if auto.custom and auto.custom.custom_enabled }}>
</div>
<div class="setting-row">
<label>定制源统一采集间隔(分钟,每源可覆盖)</label>
<input type="number" id="c_custom_scan_interval_min" value="{{ auto.custom.custom_scan_interval_min if auto.custom else 15 }}">
</div>
<div class="setting-row">
<label>单独发「定制监控汇总」邮件</label>
<input type="checkbox" id="c_custom_summary_enabled" {{ 'checked' if auto.custom and auto.custom.custom_summary_enabled }}>
</div>
<div class="setting-row">
<label>定制监控汇总时间(独立于新闻日报)</label>
<input type="time" id="c_custom_summary_time" value="{{ auto.custom.custom_summary_time if auto.custom else '18:00' }}">
</div>
<div class="setting-row">
<label>定制汇总窗口(小时)</label>
<input type="number" id="c_custom_summary_window_hours" value="{{ auto.custom.custom_summary_window_hours if auto.custom else 24 }}">
</div>
<div class="setting-row">
<label>定制汇总邮件最多条目</label>
<input type="number" id="c_custom_max_summary_items" value="{{ auto.custom.custom_max_summary_items if auto.custom else 20 }}">
</div>
</section>
<section class="card">
<h3>🤖 大模型接口
<span class="muted small">(切换后即时生效,智能分析将使用当前接口;失败自动切换下一个可用接口)</span>
@@ -132,7 +162,15 @@ async function saveAll(){
smtp_user: G('m_user'), smtp_pass: G('m_pass'), smtp_mode: G('m_mode'),
email_to: G('m_to'), sender_name: G('m_name'),
};
const r = await API.json('/api/settings', { ...auto, mail });
const custom = {
custom_enabled: document.getElementById('c_custom_enabled').checked ? 1 : 0,
custom_scan_interval_min: parseInt(G('c_custom_scan_interval_min'))||15,
custom_summary_enabled: document.getElementById('c_custom_summary_enabled').checked ? 1 : 0,
custom_summary_time: G('c_custom_summary_time')||'18:00',
custom_summary_window_hours: parseInt(G('c_custom_summary_window_hours'))||24,
custom_max_summary_items: parseInt(G('c_custom_max_summary_items'))||20,
};
const r = await API.json('/api/settings', { ...auto, mail, custom });
toast(r.ok ? '✅ 设置已保存' : '❌ 保存失败', r.ok);
}
+79 -6
View File
@@ -9,20 +9,22 @@
<div class="card">
<table>
<thead><tr><th>ID</th><th>名称</th><th>监控方式</th><th>类型</th><th>权重</th><th>状态</th><th>最近采</th><th>新增条数</th><th>操作</th></tr></thead>
<thead><tr><th>ID</th><th>名称</th><th>监控方式</th><th>类型</th><th>权重</th><th>采集周期</th><th>状态</th><th>最近采</th><th>条数</th><th>操作</th></tr></thead>
<tbody>
{% for s in sources %}
<tr data-sid="{{ s.id }}" data-name="{{ s.name }}" data-type="{{ s.type }}" data-url="{{ s.url }}" data-desc="{{ s.description }}" data-weight="{{ s.weight }}" data-kind="{{ s.kind }}" data-standard="{{ s.monitor_standard or '' }}">
<tr data-sid="{{ s.id }}" data-name="{{ s.name }}" data-type="{{ s.type }}" data-url="{{ s.url }}" data-desc="{{ s.description }}" data-weight="{{ s.weight }}" data-kind="{{ s.kind }}" data-standard="{{ s.monitor_standard or '' }}" data-interval="{{ s.scan_interval_min or 0 }}">
<td>{{ s.id }}</td>
<td><b>{{ s.name }}</b><br><span class="muted small">{{ s.description }}</span><br><span class="muted small" style="word-break:break-all;">{{ s.url }}</span></td>
<td>{% if s.kind == 'custom' %}<span class="tag hot">🎯 定制监控</span>{% else %}<span class="tag ok">普通</span>{% endif %}</td>
<td>{{ s.type }}</td>
<td>{% if s.kind == 'custom' %}<span class="muted small"></span>{% else %}{{ s.weight }}{% endif %}</td>
<td>{% if s.scan_interval_min %}{{ s.scan_interval_min }}分{% else %}<span class="muted small">默认</span>{% endif %}</td>
<td>{% if s.enabled %}<span class="tag ok">启用</span>{% else %}<span class="tag">停用</span>{% endif %}</td>
<td>{{ s.last_fetch or '—' }}</td>
<td>{{ s.last_count }}</td>
<td>
<button class="btn mini" onclick="openModal({{ s.id }})">✏️ 编辑</button>
<button class="btn mini" onclick="openHistory({{ s.id }})">📜 历史采样</button>
<button class="btn mini" onclick="toggleSource({{ s.id }})">{{ '停用' if s.enabled else '启用' }}</button>
<button class="btn mini danger" onclick="delSource({{ s.id }})">删除</button>
</td>
@@ -32,7 +34,8 @@
</table>
<div class="muted small" style="margin-top:8px;">
💡 真实 URL 源自动抓取页面并清洗可读正文入库;example.com 等占位地址走模拟数据。<br>
🎯 <b>定制监控</b>:不设权重,填写「推送标准」后由大模型逐条判断是否达到标准,达到即实时邮件推送。
⏱ 采集周期:0=跟随所属机制全局值(普通源=设置页采集间隔,定制源=定制监控间隔),填分钟数则本源自定义。<br>
🎯 <b>定制监控</b>:与新闻监控独立,无权重,按「推送标准」由大模型判断是否推送,命中实时推送 + 单独汇总。
</div>
</div>
@@ -49,10 +52,13 @@
<label>监控方式</label>
<select id="m_kind" onchange="onKindChange()">
<option value="normal">普通数据源(按权重打分推送)</option>
<option value="custom">定制监控(无权重,按推送标准由大模型判断推送)</option>
<option value="normal">普通数据源(新闻监控机制,按权重打分推送)</option>
<option value="custom">定制监控(定制监控机制,无权重,按推送标准由大模型判断推送)</option>
</select>
<label>采集周期(分钟)<span class="muted small">— 0 表示跟随所属机制的全局间隔</span></label>
<input id="m_interval" type="number" min="0" step="1" value="0" placeholder="0=跟随全局(普通源30 / 定制源15)">
<div id="m_weight_row">
<label>权重(综合评分中该源占比,0.1~2.0)</label>
<input id="m_weight" type="number" step="0.1" min="0.1" max="2" value="0.8">
@@ -82,10 +88,32 @@
</div>
</div>
</div>
<!-- 历史采样 模态框 -->
<div class="modal-mask" id="histModal" style="display:none;">
<div class="modal" style="width:640px;">
<div class="modal-head">
<h3 id="histTitle">历史采样</h3>
<span class="modal-x" onclick="document.getElementById('histModal').style.display='none'"></span>
</div>
<div class="modal-body">
<div class="form-row" style="margin-bottom:8px;">
<button class="btn mini" onclick="openHistArticles()">📰 查看本源采集资讯</button>
<span id="hist_meta" class="muted small"></span>
</div>
<div id="hist_snaps"></div>
<div id="hist_articles" style="display:none;margin-top:8px;border-top:1px solid #f3f4f6;padding-top:8px;"></div>
</div>
<div class="modal-foot">
<button class="btn" onclick="document.getElementById('histModal').style.display='none'">关闭</button>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
let editId = null;
let histSourceId = null;
function openModal(id){
editId = id || null;
@@ -101,10 +129,12 @@ function openModal(id){
setV('m_weight', row.dataset.weight || '0.8');
setV('m_kind', row.dataset.kind || 'normal');
setV('m_standard', row.dataset.standard || '');
setV('m_interval', row.dataset.interval || '0');
} else {
['m_name','m_type','m_url','m_desc','m_standard'].forEach(k=>setV(k,''));
setV('m_weight', '0.8');
setV('m_kind', 'normal');
setV('m_interval', '0');
}
onKindChange();
document.getElementById('sourceModal').style.display = 'flex';
@@ -123,7 +153,8 @@ async function saveSource(){
name: getV('m_name').trim(), type: getV('m_type').trim(), url: getV('m_url').trim(),
description: getV('m_desc').trim(), kind,
monitor_standard: kind === 'custom' ? getV('m_standard').trim() : '',
weight: parseFloat(getV('m_weight') || '1') || 1
weight: parseFloat(getV('m_weight') || '1') || 1,
scan_interval_min: parseInt(getV('m_interval')) || 0
};
if (!body.name){ toast('请填写名称', false); return; }
if (kind === 'custom' && !body.monitor_standard){ toast('请填写推送标准', false); return; }
@@ -146,6 +177,47 @@ async function testStandard(){
}
}
// ---------- 历史采样 ----------
async function openHistory(id){
histSourceId = id;
const row = document.querySelector(`tr[data-sid="${id}"]`);
document.getElementById('histTitle').textContent = `📜 历史采样 · ${row ? row.dataset.name : ''}`;
document.getElementById('hist_articles').style.display = 'none';
document.getElementById('hist_articles').innerHTML = '';
document.getElementById('histModal').style.display = 'flex';
const r = await API.get('/api/sources/history', {source_id: id, limit: 100});
document.getElementById('hist_meta').textContent = r.ok ? `${r.total} 次采样` : '加载失败';
if (!r.ok){ document.getElementById('hist_snaps').innerHTML = '<div class="empty">加载失败</div>'; return; }
const snaps = r.snapshots;
if (!snaps.length){
document.getElementById('hist_snaps').innerHTML = '<div class="empty">暂无采样记录(保存后由后台采集生成)</div>';
return;
}
document.getElementById('hist_snaps').innerHTML =
'<table><thead><tr><th>#</th><th>采样时间</th><th>采集条数</th><th>状态</th><th>备注</th></tr></thead><tbody>' +
snaps.map(s => `<tr>
<td>${s.id}</td><td>${s.fetched_at}</td><td>${s.count}</td>
<td>${s.status==='ok' ? '<span class="tag ok">成功</span>' : '<span class="tag err">失败</span>'}</td>
<td class="small muted">${escapeHtml(s.detail||'')}</td>
</tr>`).join('') + '</tbody></table>';
}
async function openHistArticles(){
if (!histSourceId) return;
const el = document.getElementById('hist_articles');
el.style.display = 'block';
el.innerHTML = '<div class="empty">⏳ 加载资讯列表...</div>';
const r = await API.get('/api/sources/articles', {source_id: histSourceId, page_size: 30});
if (!r.ok){ el.innerHTML = '<div class="empty">加载失败</div>'; return; }
const arts = r.articles;
if (!arts.length){ el.innerHTML = `<div class="empty">本源暂无采集到的资讯(共 ${r.total} 条)</div>`; return; }
el.innerHTML = `<div class="muted small" style="margin-bottom:6px;">本源共采集 ${r.total} 条资讯(最新 ${arts.length} 条):</div>` +
'<div class="hist-list">' + arts.map(a =>
`<div class="hist-item"><a href="/news/${a.id}" target="_blank"><b>${escapeHtml(a.title)}</b></a>
<div class="muted small">${a.collected_at} · 综合分${a.total_score}${a.is_important ? ' · <span style="color:#dc2626;">重要</span>' : ''}</div></div>`
).join('') + '</div>';
}
async function toggleSource(id){ await API.json('/api/sources', {action:'toggle', id}); location.reload(); }
async function delSource(id){
if (!confirm('确认删除该数据源?')) return;
@@ -153,5 +225,6 @@ async function delSource(id){
}
function getV(id){ return document.getElementById(id).value; }
function setV(id, v){ document.getElementById(id).value = v; }
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
</script>
{% endblock %}