2 Commits
13 changed files with 719 additions and 121 deletions
+21 -8
View File
@@ -2,7 +2,7 @@
一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。
> 当前版本 **v1.1.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` 提取给自动流程。
---
@@ -39,9 +41,13 @@
- 🧠 **两级智能分析**:规则打分秒级响应 + LLM 深度分析(重要度/相关度/分类/结论)
- 🤖 **大模型接口可配置**:设置页可增删改/测试大模型接口,预置 SiliconFlow(默认)/ DeepSeek 官方 / Autodl / Local Qwen 四个,**一键切换即时生效**,激活接口失败自动切换下一个可用接口
- 🔥 **实时重要资讯邮件**:综合分达到阈值自动推送,单批最多 10 条
- 🎯 **定制监控数据源**:数据源可选「定制监控」方式——**不设权重**,填写「推送标准」后由大模型逐条判断是否达到标准,达到即实时邮件推送(页面可直接「🧪 测试推送标准」);命中推送的资讯在邮件/列表中带「🎯 定制监控命中」标识
- 🔀 **两套独立机制**:新闻监控与定制监控**完全分开**——各自的采集间隔、汇总时间/窗口/条数单独配置(设置页「定制监控机制」区块);定制源命中会单独发「🎯 定制监控汇总」邮件,不混入新闻日报
-**每源独立采集周期**:全局有统一采集间隔(新闻30分/定制15分),每个数据源可用本源「采集周期」字段自定义覆盖(0=跟随全局)
- 📜 **历史采样留档**:每次采集记录时间/条数/成功失败到 `source_snapshots`,数据源页「📜 历史采样」可查看每次快照及本源采集到的资讯;`/api/sources/history``/api/sources/articles` 供自动流程提取历史数据
- 📰 **每日 AI 资讯日报**:默认每天 10:00,按领域统计 + 重点资讯卡片
- 🎯 **兴趣画像管理**:关键词(带权重)、领域、关注公司,全部可在网页维护,改完一键「重新打分」
- 🔗 **数据源管理**7 个内置数据源模板,**编辑**(名称/类型/URL/权重/描述)、启停、删除,每源带权重
- 🔗 **数据源管理**7 个内置数据源模板,**完整编辑**模态框一次性改名称/类型/URL/权重/描述/监控方式/推送标准)、启停、删除;普通源带权重,定制监控源填「推送标准」
- 📊 **网页管理台**:仪表盘(总数/重要/待分析/趋势/领域分布)、资讯列表(筛选/搜索/分页)、详情页(含全文)、通知日志
- 📧 **邮件通知**:支持 plain / starttls / ssl 三种 SMTP 模式,可测试发送
- 🛠 **手动操作台**:一键采集 / 一键 LLM 分析 / 一键发日报 / 一键造数据 / 重新打分 / 测试邮件
@@ -144,10 +150,10 @@ cd works/news-tracker
| `/dashboard` | 仪表盘:总资讯/重要资讯/待分析/已通知 + 近7天趋势 + 领域分布 |
| `/news` | 资讯列表:按领域/重要/关键词搜索,分页 |
| `/news/<id>` | 资讯详情:规则分/LLM分/实体/分析结论 + **页面可读全文** |
| `/sources` | 数据源管理:**编辑**/启停/删除/权重,真实源状态可见 |
| `/sources` | 数据源管理:**完整编辑**(模态框)/启停/删除,支持普通(权重)与定制监控(推送标准),每源独立采集周期,「📜 历史采样」可查看每次采样快照与本源资讯 |
| `/profile` | 兴趣画像:关键词/领域/公司维护 |
| `/logs` | 通知日志(实时/日报发送记录) |
| `/settings` | 设置:自动化参数 + 邮件配置 + **大模型接口管理(一键切换/测试)** |
| `/settings` | 设置:自动化参数(新闻机制)+ **定制监控机制(独立配置)** + 邮件配置 + **大模型接口管理(一键切换/测试)** |
---
@@ -156,7 +162,9 @@ 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/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=一键切换) |
@@ -168,7 +176,8 @@ cd works/news-tracker
## 数据模型
- **sources**:数据源(name/type/url/weight/enabled/status/last_fetch/last_countstatus 标记真实源采集 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=当前激活)
@@ -177,6 +186,10 @@ 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分量` 融合)
---
@@ -208,4 +221,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判断推送+测试按钮)→ **`v1.3.0`**(每源独立采集周期 / 定制监控与新闻监控分离独立配置+独立汇总 / 历史采样留档+查看+提取API)
+58 -3
View File
@@ -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
+100 -3
View File
@@ -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,18 @@ 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", ""),
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"),
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", ""),
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":
@@ -128,9 +136,84 @@ 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"})
@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 {}
@@ -170,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})
@@ -241,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)))
@@ -249,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})
@@ -279,10 +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"])
db.add_source(s["name"], s["type"], s["url"], s["description"], s["weight"],
kind=s.get("kind", "normal"),
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:
@@ -298,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()
+16 -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),
@@ -119,4 +129,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%,或重大产品发布/回购/分拆/收购等实质性公告,或分析师大幅上调目标价。普通行情波动、日常新闻一律不推送。"},
]
+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)
+86 -6
View File
@@ -18,6 +18,9 @@ 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 '', -- 定制监控:推送标准说明
scan_interval_min INTEGER DEFAULT 0, -- 本源采集间隔(分钟),0=跟随所属机制的全局值
enabled INTEGER DEFAULT 1,
status TEXT DEFAULT 'ok', -- ok / error
last_fetch TEXT DEFAULT '',
@@ -25,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,
@@ -111,6 +125,25 @@ def init_db():
os.makedirs(config.DATA_DIR, exist_ok=True)
conn = get_conn()
conn.executescript(_SCHEMA)
# 迁移:旧库补充 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:
@@ -179,11 +212,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="", scan_interval_min=0):
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,scan_interval_min,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?)",
(name, type_, url, desc, weight, kind, monitor_standard, scan_interval_min, now_str()),
)
conn.commit()
conn.close()
@@ -191,7 +225,8 @@ 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",
"scan_interval_min", "enabled"}
sets, vals = [], []
for k, v in fields.items():
if k in allowed:
@@ -223,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()
@@ -361,12 +418,13 @@ def article_stats():
def latest_articles_for_summary(window_hours=24):
"""汇总窗口内(含未通知、未汇总的)资讯"""
"""新闻机制汇总:普通源(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 (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()
@@ -381,6 +439,28 @@ def latest_articles_for_summary(window_hours=24):
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 = []
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 pending_llm_articles(limit=10):
conn = get_conn()
rows = conn.execute(
+28 -2
View File
@@ -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)} ·
@@ -102,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"]))]
@@ -129,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
+17
View File
@@ -110,5 +110,22 @@ 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; }
.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);
}
+189 -39
View File
@@ -4,36 +4,27 @@
{% 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><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 '' }}" 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>{{ s.weight }}</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="editSource({{ s.id }})">✏️ 编辑</button>
<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>
@@ -41,40 +32,199 @@
{% 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>
⏱ 采集周期:0=跟随所属机制全局值(普通源=设置页采集间隔,定制源=定制监控间隔),填分钟数则本源自定义。<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>
<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">
</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>
<!-- 历史采样 模态框 -->
<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>
function toggleAdd(){ document.getElementById('add-box').style.display = document.getElementById('add-box').style.display==='none' ? 'block' : 'none'; }
let editId = null;
let histSourceId = 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 || '');
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';
}
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,
scan_interval_min: parseInt(getV('m_interval')) || 0
};
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){
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 openHistory(id){
histSourceId = 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);
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;
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; }
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
</script>
{% endblock %}