v1.2.0: 数据源完整编辑(模态框所有配置可改) + 定制监控类型(无权重,推送标准由大模型判断推送)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。
|
||||
|
||||
> 当前版本 **v1.1.0**:真实网页采集(可读正文入库)+ 大模型接口多预置一键切换 + 数据源可编辑。
|
||||
> 当前版本 **v1.2.0**:数据源**完整编辑**(所有配置可改,模态框)+ **定制监控类型**(无权重,按「推送标准」由大模型判断是否推送)。
|
||||
|
||||
---
|
||||
|
||||
@@ -39,9 +39,10 @@
|
||||
- 🧠 **两级智能分析**:规则打分秒级响应 + LLM 深度分析(重要度/相关度/分类/结论)
|
||||
- 🤖 **大模型接口可配置**:设置页可增删改/测试大模型接口,预置 SiliconFlow(默认)/ DeepSeek 官方 / Autodl / Local Qwen 四个,**一键切换即时生效**,激活接口失败自动切换下一个可用接口
|
||||
- 🔥 **实时重要资讯邮件**:综合分达到阈值自动推送,单批最多 10 条
|
||||
- 🎯 **定制监控数据源**:数据源可选「定制监控」方式——**不设权重**,填写「推送标准」后由大模型逐条判断是否达到标准,达到即实时邮件推送(页面可直接「🧪 测试推送标准」);命中推送的资讯在邮件/列表中带「🎯 定制监控命中」标识
|
||||
- 📰 **每日 AI 资讯日报**:默认每天 10:00,按领域统计 + 重点资讯卡片
|
||||
- 🎯 **兴趣画像管理**:关键词(带权重)、领域、关注公司,全部可在网页维护,改完一键「重新打分」
|
||||
- 🔗 **数据源管理**:7 个内置数据源模板,**可编辑**(名称/类型/URL/权重/描述)、启停、删除,每源带权重
|
||||
- 🔗 **数据源管理**:7 个内置数据源模板,**完整编辑**(模态框一次性改名称/类型/URL/权重/描述/监控方式/推送标准)、启停、删除;普通源带权重,定制监控源填「推送标准」
|
||||
- 📊 **网页管理台**:仪表盘(总数/重要/待分析/趋势/领域分布)、资讯列表(筛选/搜索/分页)、详情页(含全文)、通知日志
|
||||
- 📧 **邮件通知**:支持 plain / starttls / ssl 三种 SMTP 模式,可测试发送
|
||||
- 🛠 **手动操作台**:一键采集 / 一键 LLM 分析 / 一键发日报 / 一键造数据 / 重新打分 / 测试邮件
|
||||
@@ -144,7 +145,7 @@ cd works/news-tracker
|
||||
| `/dashboard` | 仪表盘:总资讯/重要资讯/待分析/已通知 + 近7天趋势 + 领域分布 |
|
||||
| `/news` | 资讯列表:按领域/重要/关键词搜索,分页 |
|
||||
| `/news/<id>` | 资讯详情:规则分/LLM分/实体/分析结论 + **页面可读全文** |
|
||||
| `/sources` | 数据源管理:**编辑**/启停/删除/权重,真实源状态可见 |
|
||||
| `/sources` | 数据源管理:**完整编辑**(模态框)/启停/删除,支持普通(权重)与定制监控(推送标准),真实源状态可见 |
|
||||
| `/profile` | 兴趣画像:关键词/领域/公司维护 |
|
||||
| `/logs` | 通知日志(实时/日报发送记录) |
|
||||
| `/settings` | 设置:自动化参数 + 邮件配置 + **大模型接口管理(一键切换/测试)** |
|
||||
@@ -156,7 +157,7 @@ cd works/news-tracker
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/stats` | GET | 统计数据(总数/重要/趋势/领域分布) |
|
||||
| `/api/sources` | POST | `action=add\|update\|delete\|toggle` 数据源管理 |
|
||||
| `/api/sources` | POST | `action=add\|update\|delete\|toggle\|test_standard` 数据源管理(update 可改全部配置;test_standard=用大模型试测推送标准) |
|
||||
| `/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=一键切换) |
|
||||
@@ -168,7 +169,7 @@ cd works/news-tracker
|
||||
|
||||
## 数据模型
|
||||
|
||||
- **sources**:数据源(name/type/url/weight/enabled/status/last_fetch/last_count;status 标记真实源采集 ok/error)
|
||||
- **sources**:数据源(name/type/url/weight/**kind**/**monitor_standard**/enabled/status/last_fetch/last_count;`kind`=normal 普通按权重打分 / custom 定制监控按推送标准 LLM 判断;`monitor_standard`=定制监控的推送标准说明;status 标记真实源采集 ok/error)
|
||||
- **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/enabled;active=当前激活)
|
||||
@@ -177,6 +178,8 @@ cd works/news-tracker
|
||||
|
||||
`llm_status`:`pending`(待分析) → `done`(完成) / `skipped`(未达标跳过) / `error`(失败)
|
||||
|
||||
**定制监控(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分量` 融合)
|
||||
|
||||
---
|
||||
@@ -208,4 +211,4 @@ cd works/news-tracker
|
||||
## Git
|
||||
|
||||
- 仓库:`hz4th_coder/news-tracker`
|
||||
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ **`v1.1.0`**(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)
|
||||
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ `v1.1.0`(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)→ **`v1.2.0`**(数据源完整编辑模态框 / 定制监控类型:无权重+推送标准+LLM判断推送+测试按钮)
|
||||
+58
-3
@@ -142,9 +142,21 @@ def _source_weight(a):
|
||||
s = db.get_source(sid)
|
||||
if not s:
|
||||
return 0
|
||||
if s.get("kind") == "custom":
|
||||
# 定制监控无权重,是否推送由大模型按「推送标准」判断
|
||||
return 0
|
||||
return int(round(s["weight"] * 10))
|
||||
|
||||
|
||||
def source_is_custom(a):
|
||||
"""该资讯所属数据源是否为定制监控类型"""
|
||||
sid = a.get("source_id") or 0
|
||||
if not sid:
|
||||
return False
|
||||
s = db.get_source(sid)
|
||||
return bool(s and s.get("kind") == "custom")
|
||||
|
||||
|
||||
def analyze_article(aid):
|
||||
"""规则打分(立即生效)"""
|
||||
a = db.get_article(aid)
|
||||
@@ -167,6 +179,14 @@ def analyze_article(aid):
|
||||
|
||||
total = min(100, int(round(0.4 * rel + 0.6 * imp)))
|
||||
|
||||
# 定制监控:规则分仅供展示,是否推送完全由大模型按「推送标准」判断(初始标记为不推送,等 LLM 结论)
|
||||
if source_is_custom(a):
|
||||
db.update_article(aid, domain=domain, entities=entities, relevance=rel,
|
||||
total_score=total, is_important=0)
|
||||
return {"id": aid, "domain": domain, "entities": entities, "relevance": rel,
|
||||
"importance_rule": imp, "total_score": total, "is_important": 0,
|
||||
"custom": True}
|
||||
|
||||
threshold = int(db.get_setting("realtime_threshold", config.AUTO_DEFAULTS["realtime_threshold"]))
|
||||
is_important = 1 if (total >= threshold or (rel >= 65 and imp >= 70)) else 0
|
||||
|
||||
@@ -226,10 +246,14 @@ def _llm_chat(prompt):
|
||||
|
||||
|
||||
def llm_analyze(aid):
|
||||
"""LLM 深度分析单条:重要度 1-10 + 相关度 + 结论。失败则标记 error 不阻塞。"""
|
||||
"""LLM 深度分析单条。
|
||||
普通源:重要度/相关度/结论;定制监控源:按「推送标准」判断是否达到推送条件。"""
|
||||
a = db.get_article(aid)
|
||||
if not a:
|
||||
return None
|
||||
if source_is_custom(a):
|
||||
src = db.get_source(a.get("source_id") or 0)
|
||||
return _llm_standard_check(a, src)
|
||||
profile = _profile_text()
|
||||
prompt = (
|
||||
"你是一位资深科技资讯分析师,专注AI领域。\n"
|
||||
@@ -269,6 +293,36 @@ def llm_analyze(aid):
|
||||
return {"id": aid, "error": str(e)}
|
||||
|
||||
|
||||
def _llm_standard_check(a, src):
|
||||
"""定制监控源:按推送标准让大模型判断该条资讯是否达到推送条件。
|
||||
达到 → is_important=1 → 实时邮件推送;未达到 → 不推送。"""
|
||||
standard = (src.get("monitor_standard") or "").strip() or "重要资讯"
|
||||
prompt = (
|
||||
"你是一位资讯监控专员。用户配置了一个定制监控数据源,并设定了「推送标准」。\n"
|
||||
f"【推送标准】\n{standard}\n\n"
|
||||
f"【资讯标题】{a['title']}\n"
|
||||
f"【资讯内容】{a.get('content') or a.get('summary')}\n\n"
|
||||
"请严格对照推送标准判断:这条资讯是否达到应推送的程度?\n"
|
||||
"只输出一个 JSON 对象(不要任何其他文字),格式:\n"
|
||||
'{"meets_standard": true或false, "reason": "判断理由(40字内中文)"}'
|
||||
)
|
||||
try:
|
||||
content, _provider = _llm_chat(prompt)
|
||||
parsed = json.loads(content)
|
||||
meets = 1 if parsed.get("meets_standard") else 0
|
||||
reason = parsed.get("reason", "")
|
||||
db.update_article(
|
||||
a["id"], is_important=meets, analysis=reason, llm_status="done",
|
||||
importance=7 if meets else 1,
|
||||
)
|
||||
return {"id": a["id"], "meets_standard": bool(meets), "reason": reason,
|
||||
"total_score": a.get("total_score", 0), "is_important": meets,
|
||||
"custom": True}
|
||||
except Exception as e:
|
||||
db.update_article(a["id"], llm_status="error")
|
||||
return {"id": a["id"], "error": str(e)}
|
||||
|
||||
|
||||
def _profile_text():
|
||||
kws = "、".join(k["keyword"] for k in db.list_keywords() if k["enabled"])
|
||||
comps = "、".join(c["name"] for c in db.list_companies() if c["enabled"])
|
||||
@@ -277,12 +331,13 @@ def _profile_text():
|
||||
|
||||
|
||||
def batch_llm_analyze(limit=10):
|
||||
"""后台线程:对 pending 且规则分达标的资讯做 LLM 深度分析"""
|
||||
"""后台线程:对 pending 的资讯做 LLM 深度分析
|
||||
普通源:规则分达 llm_threshold 才分析;定制监控源:无条件分析(是否推送由大模型决定)。"""
|
||||
threshold = int(db.get_setting("llm_threshold", config.AUTO_DEFAULTS["llm_threshold"]))
|
||||
arts = db.pending_llm_articles(limit=limit)
|
||||
results = {"done": 0, "error": 0, "skipped": 0}
|
||||
for a in arts:
|
||||
if a.get("total_score", 0) < threshold:
|
||||
if a.get("total_score", 0) < threshold and not source_is_custom(a):
|
||||
db.update_article(a["id"], llm_status="skipped")
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
|
||||
@@ -6,6 +6,8 @@ API:采集 / LLM分析 / 汇总 / 画像维护 / 设置维护
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import json
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
||||
|
||||
import requests
|
||||
@@ -113,12 +115,16 @@ def api_sources():
|
||||
action = data.get("action")
|
||||
if action == "add":
|
||||
sid = db.add_source(data.get("name", ""), data.get("type", ""), data.get("url", ""),
|
||||
data.get("description", ""), float(data.get("weight", 1.0)))
|
||||
data.get("description", ""), float(data.get("weight", 1.0)),
|
||||
kind=data.get("kind", "normal"),
|
||||
monitor_standard=data.get("monitor_standard", ""))
|
||||
return jsonify({"ok": True, "id": sid})
|
||||
if action == "update":
|
||||
db.update_source(data["id"], name=data.get("name"), type=data.get("type"),
|
||||
url=data.get("url"), description=data.get("description"),
|
||||
weight=float(data.get("weight", 1.0)),
|
||||
kind=data.get("kind", "normal"),
|
||||
monitor_standard=data.get("monitor_standard", ""),
|
||||
enabled=1 if data.get("enabled") else 0)
|
||||
return jsonify({"ok": True})
|
||||
if action == "delete":
|
||||
@@ -128,6 +134,27 @@ def api_sources():
|
||||
s = db.get_source(data["id"])
|
||||
db.update_source(data["id"], enabled=0 if s["enabled"] else 1)
|
||||
return jsonify({"ok": True})
|
||||
if action == "test_standard":
|
||||
# 定制监控:用大模型测试「推送标准」是否好使(给一段示例内容看判不判得出)
|
||||
standard = (data.get("monitor_standard") or "").strip()
|
||||
sample = (data.get("sample") or "").strip()
|
||||
if not standard:
|
||||
return jsonify({"ok": False, "error": "请先填写推送标准"})
|
||||
prompt = (
|
||||
"你是一位资讯监控专员。用户配置了一个定制监控数据源,并设定了「推送标准」。\n"
|
||||
f"【推送标准】\n{standard}\n\n"
|
||||
f"【待判断内容】\n{sample or '(未提供示例内容,请自行用一句典型的需推送场景作答,说明是否达到标准)'}\n\n"
|
||||
"请判断该内容是否达到推送标准。只输出一个 JSON 对象(不要任何其他文字):\n"
|
||||
'{"meets_standard": true或false, "reason": "判断理由(40字内中文)"}'
|
||||
)
|
||||
try:
|
||||
content, _name = analysis._llm_chat(prompt)
|
||||
parsed = json.loads(content)
|
||||
return jsonify({"ok": True,
|
||||
"meets": bool(parsed.get("meets_standard")),
|
||||
"reason": parsed.get("reason", "")})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)})
|
||||
return jsonify({"ok": False, "error": "unknown action"})
|
||||
|
||||
|
||||
@@ -282,7 +309,9 @@ def main():
|
||||
# 首次初始化:写入默认数据源 / 兴趣画像 / 默认设置 / 模拟数据
|
||||
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"])
|
||||
db.add_source(s["name"], s["type"], s["url"], s["description"], s["weight"],
|
||||
kind=s.get("kind", "normal"),
|
||||
monitor_standard=s.get("monitor_standard", ""))
|
||||
for kw, w in config.DEFAULT_KEYWORDS:
|
||||
db.add_keyword(kw, w)
|
||||
for d, w in config.DEFAULT_DOMAINS:
|
||||
|
||||
@@ -119,4 +119,8 @@ DEFAULT_SOURCES = [
|
||||
"description": "行业数据报告、市场调研(模拟源,可改真实 URL)", "weight": 0.7},
|
||||
{"name": "科技投资动态", "type": "投融资", "url": "https://example.com/funding",
|
||||
"description": "AI 领域融资、并购、IPO 动态(模拟源,可改真实 URL)", "weight": 0.9},
|
||||
# 定制监控示例:无权重,按「推送标准」由大模型判断是否推送
|
||||
{"name": "英伟达重大异动监控", "type": "定制监控", "url": "https://example.com/custom-nvda",
|
||||
"description": "定制监控示例:英伟达重大异动(可改真实 URL)", "kind": "custom",
|
||||
"monitor_standard": "仅推送英伟达(NVIDIA)重大事件:股价单日涨跌幅超10%,或重大产品发布/回购/分拆/收购等实质性公告,或分析师大幅上调目标价。普通行情波动、日常新闻一律不推送。"},
|
||||
]
|
||||
@@ -18,6 +18,8 @@ CREATE TABLE IF NOT EXISTS sources (
|
||||
url TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
weight REAL DEFAULT 1.0,
|
||||
kind TEXT DEFAULT 'normal', -- normal 普通(按权重打分) / custom 定制监控(按推送标准LLM判断)
|
||||
monitor_standard TEXT DEFAULT '', -- 定制监控:推送标准说明
|
||||
enabled INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'ok', -- ok / error
|
||||
last_fetch TEXT DEFAULT '',
|
||||
@@ -111,6 +113,12 @@ def init_db():
|
||||
os.makedirs(config.DATA_DIR, exist_ok=True)
|
||||
conn = get_conn()
|
||||
conn.executescript(_SCHEMA)
|
||||
# 迁移:旧库补充 sources.kind / monitor_standard(定制监控类型)
|
||||
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 ''")
|
||||
# 迁移:旧库补充 full_text 列(存页面可读正文)
|
||||
cols = [r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()]
|
||||
if "full_text" not in cols:
|
||||
@@ -179,11 +187,12 @@ def get_source(sid):
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def add_source(name, type_, url, desc, weight=1.0):
|
||||
def add_source(name, type_, url, desc, weight=1.0, kind="normal", monitor_standard=""):
|
||||
conn = get_conn()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO sources(name,type,url,description,weight,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(name, type_, url, desc, weight, now_str()),
|
||||
"INSERT INTO sources(name,type,url,description,weight,kind,monitor_standard,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?,?)",
|
||||
(name, type_, url, desc, weight, kind, monitor_standard, now_str()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -191,7 +200,7 @@ def add_source(name, type_, url, desc, weight=1.0):
|
||||
|
||||
|
||||
def update_source(sid, **fields):
|
||||
allowed = {"name", "type", "url", "description", "weight", "enabled"}
|
||||
allowed = {"name", "type", "url", "description", "weight", "kind", "monitor_standard", "enabled"}
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if k in allowed:
|
||||
@@ -361,13 +370,15 @@ def article_stats():
|
||||
|
||||
|
||||
def latest_articles_for_summary(window_hours=24):
|
||||
"""汇总窗口内(含未通知、未汇总的)资讯"""
|
||||
"""汇总窗口内(含未通知、未汇总的)资讯
|
||||
普通源按 total_score>=50;定制监控命中推送标准(is_important=1)的也纳入,不看分数。"""
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT a.*, s.name AS source_name 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 ORDER BY a.total_score DESC LIMIT 200" % int(window_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)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
out = []
|
||||
|
||||
+5
-1
@@ -53,11 +53,15 @@ def _score_color(score):
|
||||
|
||||
def _card(art):
|
||||
ents = "、".join(art.get("entities") or [])
|
||||
src = db.get_source(art.get("source_id") or 0) if art.get("source_id") else None
|
||||
custom = bool(src and src.get("kind") == "custom")
|
||||
badge = ('<span style="background:#ede9fe;color:#6d28d9;padding:2px 8px;'
|
||||
'border-radius:6px;font-size:11px;margin-right:6px;">🎯 定制监控命中</span>') if custom else ""
|
||||
return f"""
|
||||
<div style="border:1px solid #e5e7eb;border-left:4px solid {_score_color(art.get('total_score',0))};
|
||||
border-radius:6px;padding:10px 14px;margin:8px 0;">
|
||||
<div style="font-size:14px;font-weight:bold;color:#1f2937;">
|
||||
<a href="{art.get('url','#')}" style="color:#1f2937;text-decoration:none;">{art['title']}</a>
|
||||
{badge}<a href="{art.get('url','#')}" style="color:#1f2937;text-decoration:none;">{art['title']}</a>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin:4px 0;">
|
||||
综合分 {art.get('total_score',0)} · 相关度 {art.get('relevance',0)} ·
|
||||
|
||||
@@ -110,5 +110,19 @@ textarea { width:100%; }
|
||||
.toast.ok { background:#059669; }
|
||||
.toast.err { background:#dc2626; }
|
||||
|
||||
/* 模态框(数据源新增/编辑) */
|
||||
.modal-mask { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(17,24,39,.55); display:flex; align-items:center; justify-content:center; z-index:50; }
|
||||
.modal { background:#fff; border-radius:14px; width:560px; max-width:92vw; max-height:88vh; overflow:auto; box-shadow:0 10px 30px rgba(0,0,0,.2); }
|
||||
.modal-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid #f3f4f6; }
|
||||
.modal-head h3 { font-size:16px; }
|
||||
.modal-x { cursor:pointer; font-size:18px; color:#9ca3af; padding:4px; }
|
||||
.modal-x:hover { color:#dc2626; }
|
||||
.modal-body { padding:16px 20px; }
|
||||
.modal-body label { display:block; font-size:12px; color:#6b7280; margin:12px 0 4px; }
|
||||
.modal-body input, .modal-body select, .modal-body textarea { width:100%; padding:8px 10px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; }
|
||||
.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; }
|
||||
|
||||
/* 页面可读全文(干净排版) */
|
||||
.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; }
|
||||
+117
-40
@@ -4,36 +4,25 @@
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>📡 数据源管理</h1>
|
||||
<button class="btn accent" onclick="toggleAdd()">+ 新增数据源</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="add-box" style="display:none;">
|
||||
<h3>新增数据源</h3>
|
||||
<div class="form-row">
|
||||
<input id="f_name" placeholder="名称(如:36氪 AI频道)">
|
||||
<input id="f_type" placeholder="类型(科技媒体/公司动态/学术/政策...)">
|
||||
<input id="f_url" placeholder="URL / RSS 地址(example.com 开头走模拟数据)">
|
||||
<input id="f_weight" type="number" step="0.1" value="0.8" placeholder="权重">
|
||||
</div>
|
||||
<textarea id="f_desc" placeholder="描述(该源提供哪些内容)" rows="2"></textarea>
|
||||
<div class="form-row"><button class="btn" onclick="saveSource()">保存</button></div>
|
||||
<button class="btn accent" onclick="openModal()">+ 新增数据源</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>ID</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></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 }}">
|
||||
<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 '' }}">
|
||||
<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>{{ s.weight }}</td>
|
||||
<td>{% if s.kind == 'custom' %}<span class="muted small">—</span>{% else %}{{ s.weight }}{% 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="editSource({{ s.id }})">✏️ 编辑</button>
|
||||
<button class="btn mini" onclick="openModal({{ 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>
|
||||
@@ -41,40 +30,128 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="muted small" style="margin-top:8px;">💡 真实 URL 源会自动抓取页面并清洗可读正文入库;example.com 等占位地址走模拟数据。</div>
|
||||
<div class="muted small" style="margin-top:8px;">
|
||||
💡 真实 URL 源自动抓取页面并清洗可读正文入库;example.com 等占位地址走模拟数据。<br>
|
||||
🎯 <b>定制监控</b>:不设权重,填写「推送标准」后由大模型逐条判断是否达到标准,达到即实时邮件推送。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑 数据源 模态框 -->
|
||||
<div class="modal-mask" id="sourceModal" style="display:none;">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h3 id="modalTitle">新增数据源</h3>
|
||||
<span class="modal-x" onclick="closeModal()">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label>名称 *</label>
|
||||
<input id="m_name" placeholder="如:36氪 AI频道">
|
||||
|
||||
<label>监控方式</label>
|
||||
<select id="m_kind" onchange="onKindChange()">
|
||||
<option value="normal">普通数据源(按权重打分推送)</option>
|
||||
<option value="custom">定制监控(无权重,按推送标准由大模型判断推送)</option>
|
||||
</select>
|
||||
|
||||
<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">
|
||||
</div>
|
||||
|
||||
<div id="m_standard_row" style="display:none;">
|
||||
<label>推送标准 *(大模型据此判断每条资讯是否达到推送条件)</label>
|
||||
<textarea id="m_standard" rows="3" placeholder="例如:仅推送英伟达重大事件——股价单日涨跌超10%,或重大产品发布/回购/分拆/收购等实质性公告,或分析师大幅上调目标价;普通行情波动与日常新闻一律不推送。"></textarea>
|
||||
<div class="form-row" style="justify-content:flex-start;">
|
||||
<button class="btn mini" type="button" onclick="testStandard()">🧪 测试推送标准</button>
|
||||
<span id="m_test_result" class="muted small"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>类型(分类标签)</label>
|
||||
<input id="m_type" placeholder="科技媒体/公司动态/学术/政策...">
|
||||
|
||||
<label>URL / RSS 地址</label>
|
||||
<input id="m_url" placeholder="example.com 开头走模拟数据">
|
||||
|
||||
<label>描述</label>
|
||||
<textarea id="m_desc" rows="2" placeholder="该源提供哪些内容"></textarea>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" onclick="closeModal()">取消</button>
|
||||
<button class="btn accent" onclick="saveSource()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
function toggleAdd(){ document.getElementById('add-box').style.display = document.getElementById('add-box').style.display==='none' ? 'block' : 'none'; }
|
||||
let editId = null;
|
||||
|
||||
function openModal(id){
|
||||
editId = id || null;
|
||||
document.getElementById('modalTitle').textContent = editId ? '编辑数据源' : '新增数据源';
|
||||
document.getElementById('m_test_result').textContent = '';
|
||||
if (editId){
|
||||
const row = document.querySelector(`tr[data-sid="${editId}"]`);
|
||||
if (!row) return;
|
||||
setV('m_name', row.dataset.name);
|
||||
setV('m_type', row.dataset.type);
|
||||
setV('m_url', row.dataset.url);
|
||||
setV('m_desc', row.dataset.desc);
|
||||
setV('m_weight', row.dataset.weight || '0.8');
|
||||
setV('m_kind', row.dataset.kind || 'normal');
|
||||
setV('m_standard', row.dataset.standard || '');
|
||||
} else {
|
||||
['m_name','m_type','m_url','m_desc','m_standard'].forEach(k=>setV(k,''));
|
||||
setV('m_weight', '0.8');
|
||||
setV('m_kind', 'normal');
|
||||
}
|
||||
onKindChange();
|
||||
document.getElementById('sourceModal').style.display = 'flex';
|
||||
}
|
||||
function closeModal(){ document.getElementById('sourceModal').style.display = 'none'; editId = null; }
|
||||
|
||||
function onKindChange(){
|
||||
const custom = getV('m_kind') === 'custom';
|
||||
document.getElementById('m_weight_row').style.display = custom ? 'none' : 'block';
|
||||
document.getElementById('m_standard_row').style.display = custom ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function saveSource(){
|
||||
const r = await API.json('/api/sources', {
|
||||
action:'add', name:val('f_name'), type:val('f_type'), url:val('f_url'),
|
||||
weight:parseFloat(val('f_weight')||'1'), description:val('f_desc')
|
||||
});
|
||||
if (r.ok) { toast('✅ 已添加'); setTimeout(()=>location.reload(), 600); } else toast('❌ 添加失败', false);
|
||||
const kind = getV('m_kind');
|
||||
const body = {
|
||||
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
|
||||
};
|
||||
if (!body.name){ toast('请填写名称', false); return; }
|
||||
if (kind === 'custom' && !body.monitor_standard){ toast('请填写推送标准', false); return; }
|
||||
const r = await API.json('/api/sources',
|
||||
editId ? {...body, action:'update', id: editId} : {...body, action:'add'});
|
||||
toast(r.ok ? '✅ 已保存' : '❌ 保存失败', r.ok);
|
||||
if (r.ok){ closeModal(); setTimeout(()=>location.reload(), 400); }
|
||||
}
|
||||
async function editSource(id){
|
||||
const row = document.querySelector(`tr[data-sid="${id}"]`);
|
||||
if (!row) return;
|
||||
const name = prompt('名称', row.dataset.name);
|
||||
if (name === null) return;
|
||||
const type = prompt('类型', row.dataset.type);
|
||||
if (type === null) return;
|
||||
const url = prompt('URL / RSS 地址', row.dataset.url);
|
||||
if (url === null) return;
|
||||
const weight = parseFloat(prompt('权重', row.dataset.weight) || '1') || 1;
|
||||
const desc = prompt('描述', row.dataset.desc);
|
||||
if (desc === null) return;
|
||||
const r = await API.json('/api/sources', {action:'update', id, name, type, url, weight, description:desc});
|
||||
toast(r.ok ? '✅ 已更新' : '❌ 更新失败', r.ok);
|
||||
if (r.ok) setTimeout(()=>location.reload(), 400);
|
||||
|
||||
async function testStandard(){
|
||||
const standard = getV('m_standard').trim();
|
||||
if (!standard){ toast('请先填写推送标准', false); return; }
|
||||
const el = document.getElementById('m_test_result');
|
||||
el.textContent = '⏳ 大模型分析中...';
|
||||
const r = await API.json('/api/sources', {action:'test_standard', monitor_standard: standard});
|
||||
if (r.ok){
|
||||
el.textContent = r.meets ? '✅ 命中标准,会推送 · ' + (r.reason||'') : '❌ 未达标准,不推送 · ' + (r.reason||'');
|
||||
} else {
|
||||
el.textContent = '❌ ' + (r.error || '测试失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSource(id){ await API.json('/api/sources', {action:'toggle', id}); location.reload(); }
|
||||
async function delSource(id){
|
||||
if (!confirm('确认删除该数据源?')) return;
|
||||
await API.json('/api/sources', {action:'delete', id}); toast('✅ 已删除'); setTimeout(()=>location.reload(), 400);
|
||||
}
|
||||
function val(id){ return document.getElementById(id).value; }
|
||||
function getV(id){ return document.getElementById(id).value; }
|
||||
function setV(id, v){ document.getElementById(id).value = v; }
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user