v1.4.0: 通知日志分页+筛选 / 仪表盘TOP10+更多链接 / 数据源走web-capture-api抓取(获取方式与参数可编辑) / 系统错误邮件通知(频率+静默时段)

This commit is contained in:
2026-08-30 19:03:50 +08:00
parent de08530958
commit e8c8889460
14 changed files with 812 additions and 57 deletions
+31 -16
View File
@@ -38,6 +38,9 @@
## 功能特性
- 📥 **真实网页采集**:真实 URL 源自动抓取 → readability 风格清洗出干净可读正文 → **全文入库**(详情页可看),提取候选资讯链接按文章相似度排序;`example.com` 占位源走仿真数据;失败源标记 error 不造假
- 🌐 **web-capture-api 网页提取**:数据源真实网页**默认通过 web-capture-api 项目接口抓取**(反爬强、可滚动态/选 agent-browser|playwright 后端),接口地址在设置页可改;每个数据源可**单独选择获取方式**(自动 / 仅 web-capture-api / 直接抓取)并**编辑抓取参数**(抓取动作 html/text、等待时间、滚动次数、后端、高级 JSON)
- 📋 **通知日志分页 + 筛选**:通知日志支持分页查看,可按**类型**(实时/新闻汇总/定制汇总/系统错误)、**状态**(成功/失败)、**关键词**搜索
- ⚠️ **系统错误邮件通知**:采集/分析/通知/汇总/系统异常自动记录并邮件通知,可设置**通知频率(冷却分钟)** 与 **静默时段**(多段/跨午夜,静默期不发、结束后自动补发)
- 🧠 **两级智能分析**:规则打分秒级响应 + LLM 深度分析(重要度/相关度/分类/结论)
- 🤖 **大模型接口可配置**:设置页可增删改/测试大模型接口,预置 SiliconFlow(默认)/ DeepSeek 官方 / Autodl / Local Qwen 四个,**一键切换即时生效**,激活接口失败自动切换下一个可用接口
- 🔥 **实时重要资讯邮件**:综合分达到阈值自动推送,单批最多 10 条
@@ -57,7 +60,7 @@
## 技术栈
- **后端**Python 3 + Flaskthreaded
- **网页采集**requests + BeautifulSoup/lxmlreadability 风格正文清洗)
- **网页采集**优先 **web-capture-api**agent-browser / Playwright 双后端,反爬强);直接抓取回退用 requests + BeautifulSoup/lxmlreadability 风格正文清洗)
- **数据库**SQLite(原生 sqlite3WAL 模式,无 ORM
- **大模型**OpenAI 兼容接口,默认 SiliconFlow `deepseek-ai/DeepSeek-V4-Flash`(可一键切换,`response_format=json_object`
- **前端**:原生 HTML/CSS/JS + Jinja2 模板(无前端框架)
@@ -75,7 +78,7 @@ news-tracker/
├── scheduler.py # 后台调度器:定时采集 + 每日日报 + 实时通知扫描
├── analysis.py # 智能分析引擎:规则打分 + LLM 深度分析(多接口自动切换)
├── simulate.py # 模拟数据源(example.com 占位源 / 真实源失败时回退)
├── notifier.py # 邮件通知(实时 / 日报plain/starttls/ssl
├── notifier.py # 邮件通知(实时 / 日报 / 系统错误,含通知频率+静默时段控制
├── start.sh # 启动/停止/状态脚本
├── templates/ # Jinja2 页面模板
├── static/ # 静态资源
@@ -141,19 +144,27 @@ cd works/news-tracker
`CRAWL_DEFAULTS``per_source_links`(每源最多采集条目,默认 8)、`full_fetch_links`(其中抓全文条数,默认 5)、`crawl_timeout`(单页超时,默认 20s)、`user_agent`
### web-capture-api(设置页)
`WEBCAPTURE_DEFAULTS``enabled`(是否启用)、`api_url`(默认 `http://121.40.164.32:16025`,可改)、`timeout`(单次抓取超时秒)。数据源页可对每个源选**获取方式**(`auto`=优先 web-capture-api、失败回退直接抓取;`webcapture`=仅走网页提取服务;`direct`=直接抓取)并编辑**抓取参数**`capture_params` JSON`action` html|text、`wait_time` 等待加载 ms、`scroll_times` 滚动次数、`backend` auto|agent-browser|playwright,以及任意高级参数如 `scroll_delay`/`full_page`/`viewport`)。
### 系统错误邮件通知(设置页)
`ERRNOTIFY_DEFAULTS``enabled`(是否启用)、`mode``immediate` 立即 / `cooldown` 按冷却聚合 / `off` 关闭)、`cooldown_min`(**通知频率**:两次错误邮件最小间隔分钟,默认 60)、`quiet_enabled` + `quiet_periods`**静默时段**,每段 `HH:MM-HH:MM`,支持多段/跨午夜,静默期不发、结束后自动补发)、`max_items`(单封最多错误条数)。错误记录在 `system_error_log` 表(同类合并计数),发送后写 `notification_log(type=error)`
---
## 网页页面
| 路由 | 说明 |
|------|------|
| `/dashboard` | 仪表盘:总资讯/重要资讯/待分析/已通知 + 近7天趋势 + 领域分布 |
| `/dashboard` | 仪表盘:总资讯/重要资讯/待分析/已通知 + 近7天趋势 + 领域分布(重要资讯 TOP / 最新收录各显示最多 10 条,含「更多」链接) |
| `/news` | 资讯列表:按领域/重要/关键词搜索,分页 |
| `/news/<id>` | 资讯详情:规则分/LLM分/实体/分析结论 + **页面可读全文** |
| `/sources` | 数据源管理:**完整编辑**(模态框)/启停/删除,支持普通(权重)与定制监控(推送标准),每源独立采集周期,「📜 历史采样」可查看每次采样快照与本源资讯 |
| `/sources` | 数据源管理:**完整编辑**(模态框,含获取方式+抓取参数)/启停/删除,支持普通(权重)与定制监控(推送标准),每源独立采集周期,「📜 历史采样」可查看每次采样快照与本源资讯 |
| `/profile` | 兴趣画像:关键词/领域/公司维护 |
| `/logs` | 通知日志(实时/日报发送记录) |
| `/settings` | 设置:自动化参数(新闻机制)+ **定制监控机制(独立配置)** + 邮件配置 + **大模型接口管理(一键切换/测试)** |
| `/logs` | 通知日志:**分页 + 类型/状态/关键词筛选**,支持测试邮件/测试错误通知 |
| `/settings` | 设置:自动化参数(新闻机制)+ **定制监控机制(独立配置)** + **网页提取服务(web-capture-api** + **系统错误邮件通知(频率/静默)** + 邮件配置 + **大模型接口管理(一键切换/测试)** |
---
@@ -166,9 +177,11 @@ cd works/news-tracker
| `/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/settings` | POST | 更新 `auto` / `mail` / `custom` / `webcapture` / `errnotify` 设置 |
| `/api/llm` | POST | 大模型接口:`add\|update\|delete\|switch\|toggle\|test`switch=一键切换) |
| `/api/actions` | POST | `action=collect`(采集) `llm`(LLM分析) `summary`(发日报) `seed`(造数据) `reanalyze`(重新打分) `test_mail`(测试邮件) |
| `/api/actions` | POST | `action=collect`(采集) `llm`(LLM分析) `summary`(发日报) `seed`(造数据) `reanalyze`(重新打分) `test_mail`(测试邮件) `test_error_mail`(测试错误通知) `test_webcapture`(测试网页提取服务) |
| `/api/logs` | GET | 通知日志分页:`?page=&page_size=&type=&status=&q=` |
| `/api/errors` | GET | 系统错误日志:`?limit=`(最近 N 条,含累计次数/首末次时间/通知状态) |
请求体为 JSON,如:`{"action": "collect"}``{"action": "llm", "limit": 10}`
@@ -176,13 +189,14 @@ cd works/news-tracker
## 数据模型
- **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
- **sources**:数据源(name/type/url/weight/**kind**/**monitor_standard**/**scan_interval_min**/**fetch_method**/**capture_params**/enabled/status/last_fetch/last_count`kind`=normal 普通按权重打分 / custom 定制监控按推送标准 LLM 判断;`monitor_standard`=定制监控的推送标准说明;`scan_interval_min`=本源采集间隔分钟数,0=跟随所属机制全局值;`fetch_method`=auto 优先 web-capture-api 失败回退直接抓取 / webcapture 仅走网页提取服务 / direct 直接抓取;`capture_params`=web-capture-api 抓取参数 JSONstatus 标记真实源采集 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=当前激活)
- **notification_log**:邮件通知日志
- **settings**KV 配置(`auto` / `mail` 为 JSON
- **notification_log**:邮件通知日志typerealtime / summary / custom_summary / error
- **system_error_log**:系统错误日志(source/message/detail/count/pending/first_seen/last_seen/last_notified/notified_count,同类错误合并计数,pending=待通知
- **settings**KV 配置(`auto` / `mail` / `custom` / `webcapture` / `errnotify` 为 JSON
`llm_status``pending`(待分析) → `done`(完成) / `skipped`(未达标跳过) / `error`(失败)
@@ -199,11 +213,11 @@ cd works/news-tracker
系统已内置真实网页采集(`crawler.py`),接入真实源**无需写代码**
1. 在数据源页「编辑」已有源或「新增」源,填真实 URL(如媒体频道/官网新闻/RSS 页);
2. 保存后下次采集会自动抓取该页 → 清洗出可读正文 → 提取资讯链接 → 对前 5 条抓全文入库;
3. 抓取失败的源会在数据源页标记 **error** 状态(网络被拒/反爬),不阻塞其他源
2. 保存后下次采集会自动通过 **web-capture-api** 抓取该页 → 清洗出可读正文 → 提取资讯链接 → 对前条抓全文入库(每源可在「获取方式/抓取参数」里调:抓取动作、等待时间、滚动次数、后端等)
3. 抓取失败的源会在数据源页标记 **error** 状态(网络被拒/反爬),并触发**系统错误邮件通知**(受频率/静默控制)
4. `example.com` 开头的占位地址继续走模拟数据,方便调试。
> 提示:部分站点(Next.js 重 JS 渲染 / 强反爬)服务端 HTML 拿不到正文,会提取不到链接(count=0);本机对 arXiv/GitHub/OpenAI 等站网络不稳定,建议优先添加可达的站点
> 提示:web-capture-api 接口地址在设置页「网页提取服务」可改、可「测试接口」;个别重 JS 渲染/强反爬站点可改 `action=text` 整页取正文,或换 `backend=playwright`
---
@@ -214,11 +228,12 @@ cd works/news-tracker
- **真实源采集不到?** 数据源页看该源状态是否 **error**(网络被拒/反爬);可换成可达的站点或 RSS 地址。
- **想立即看效果?** 数据管理页(`/api/actions` 手动)点「立即采集 / LLM 分析 / 发送日报」。
- **改了兴趣画像想重新算分?** 点「重新打分」(reanalyze)即可全量重跑规则打分。
- **日志在哪?** `logs/app.log`(服务日志)+ 网页 `/logs`(通知日志)。
- **日志在哪?** `logs/app.log`(服务日志)+ 网页 `/logs`(通知日志,可分页筛选)。
- **系统出错没收到邮件?** 设置页「系统错误邮件通知」确认已启用、通知方式非「关闭」、冷却分钟不宜过大,且当前不在静默时段(静默时段不发送,结束后自动补发)。
---
## Git
- 仓库:`hz4th_coder/news-tracker`
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ `v1.1.0`(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)→ `v1.2.0`(数据源完整编辑模态框 / 定制监控类型:无权重+推送标准+LLM判断推送+测试按钮)→ **`v1.3.0`**(每源独立采集周期 / 定制监控与新闻监控分离独立配置+独立汇总 / 历史采样留档+查看+提取API)
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ `v1.1.0`(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)→ `v1.2.0`(数据源完整编辑模态框 / 定制监控类型:无权重+推送标准+LLM判断推送+测试按钮)→ **`v1.3.0`**(每源独立采集周期 / 定制监控与新闻监控分离独立配置+独立汇总 / 历史采样留档+查看+提取API)**`v1.4.0`**(通知日志分页+筛选 / 仪表盘 TOP10+更多链接 / 数据源走 web-capture-api 抓取+获取方式与参数可编辑 / 系统错误邮件通知:频率+静默时段)
+17
View File
@@ -290,6 +290,12 @@ def llm_analyze(aid):
"total_score": total, "is_important": is_important, "reason": reason}
except Exception as e:
db.update_article(aid, llm_status="error")
if "所有大模型接口调用失败" in str(e):
try:
import notifier
notifier.report_error("分析", "LLM 接口全部不可用", str(e)[:300])
except Exception:
pass
return {"id": aid, "error": str(e)}
@@ -320,6 +326,12 @@ def _llm_standard_check(a, src):
"custom": True}
except Exception as e:
db.update_article(a["id"], llm_status="error")
if "所有大模型接口调用失败" in str(e):
try:
import notifier
notifier.report_error("分析", "LLM 接口全部不可用", str(e)[:300])
except Exception:
pass
return {"id": a["id"], "error": str(e)}
@@ -355,6 +367,11 @@ def run_llm_background(limit=8):
batch_llm_analyze(limit=limit)
except Exception as e:
db.add_log("realtime", "LLM分析异常", 0, [], status="error", detail=str(e))
try:
import notifier
notifier.report_error("分析", "LLM 深度分析异常", str(e)[:300])
except Exception:
pass
t = threading.Thread(target=_job, daemon=True)
t.start()
return t
+107 -6
View File
@@ -31,8 +31,8 @@ def index():
@app.route("/dashboard")
def dashboard():
stats = db.article_stats()
latest = db.list_articles(limit=12, order="a.collected_at DESC")
important = db.list_articles(is_important=1, limit=12, order="a.total_score DESC")
latest = db.list_articles(limit=10, order="a.collected_at DESC")
important = db.list_articles(is_important=1, limit=10, order="a.total_score DESC")
dom_rows = db.get_conn().execute(
"SELECT domain, COUNT(*) c FROM articles GROUP BY domain ORDER BY c DESC").fetchall()
domain_stats = [{"name": r["domain"] or "未分类", "count": r["c"]} for r in dom_rows]
@@ -86,13 +86,62 @@ def profile():
@app.route("/logs")
def logs():
return render_template("logs.html", logs=db.list_logs(limit=100))
f_type = request.args.get("type", "")
f_status = request.args.get("status", "")
q = request.args.get("q", "")
page = max(1, int(request.args.get("page", 1)))
per = 20
total, logs = db.list_logs(page=page, page_size=per,
type_=f_type or None, status=f_status or None, q=q or None)
pages = max(1, (total + per - 1) // per)
return render_template("logs.html", logs=logs, f_type=f_type, f_status=f_status,
q=q, page=page, pages=pages, total=total)
@app.route("/api/logs")
def api_logs():
"""通知日志分页查询(JSON
GET /api/logs?page=&page_size=&type=&status=&q=
"""
f_type = request.args.get("type", "")
f_status = request.args.get("status", "")
q = request.args.get("q", "")
page = max(1, request.args.get("page", 1, type=int))
page_size = min(100, max(1, request.args.get("page_size", 20, type=int)))
total, rows = db.list_logs(page=page, page_size=page_size,
type_=f_type or None, status=f_status or None, q=q or None)
return jsonify({"ok": True, "logs": rows, "page": page, "page_size": page_size,
"total": total, "pages": max(1, (total + page_size - 1) // page_size)})
@app.route("/api/errors")
def api_errors():
"""系统错误日志(最近 N 条)"""
limit = min(200, request.args.get("limit", 100, type=int))
return jsonify({"ok": True, "errors": db.list_system_errors(limit=limit)})
@app.route("/settings")
def settings_page():
return render_template("settings.html", auto=db.get_all_settings(),
providers=db.list_providers())
auto = db.get_all_settings()
# 归一化(兼容老库嵌套数据),并补齐默认值,保证模板字段齐全
a0 = auto.get("auto")
if not isinstance(a0, dict) or "auto_collect" not in a0:
a0 = {}
for k in config.AUTO_DEFAULTS:
a0[k] = (auto.get("auto") or {}).get(k, config.AUTO_DEFAULTS[k])
auto["auto"] = a0
for k, dft in (("mail", config.MAIL_DEFAULTS), ("custom", config.CUSTOM_DEFAULTS),
("webcapture", config.WEBCAPTURE_DEFAULTS),
("errnotify", config.ERRNOTIFY_DEFAULTS)):
v = auto.get(k)
if not isinstance(v, dict):
auto[k] = dict(dft)
else:
merged = dict(dft)
merged.update(v)
auto[k] = merged
return render_template("settings.html", auto=auto, providers=db.list_providers())
# ---------------- API ----------------
@@ -118,7 +167,9 @@ def api_sources():
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))
scan_interval_min=int(data.get("scan_interval_min", 0) or 0),
fetch_method=data.get("fetch_method", "auto") or "auto",
capture_params=data.get("capture_params", "{}") or "{}")
return jsonify({"ok": True, "id": sid})
if action == "update":
db.update_source(data["id"], name=data.get("name"), type=data.get("type"),
@@ -127,6 +178,8 @@ def api_sources():
kind=data.get("kind", "normal"),
monitor_standard=data.get("monitor_standard", ""),
scan_interval_min=int(data.get("scan_interval_min", 0) or 0),
fetch_method=data.get("fetch_method", "auto") or "auto",
capture_params=data.get("capture_params", "{}") or "{}",
enabled=1 if data.get("enabled") else 0)
return jsonify({"ok": True})
if action == "delete":
@@ -257,6 +310,14 @@ def api_settings():
cur = db.get_all_settings().get("custom", {})
cur.update(data["custom"])
db.set_setting("custom", cur)
if "webcapture" in data and isinstance(data["webcapture"], dict):
cur = db.get_all_settings().get("webcapture", {})
cur.update(data["webcapture"])
db.set_setting("webcapture", cur)
if "errnotify" in data and isinstance(data["errnotify"], dict):
cur = db.get_all_settings().get("errnotify", {})
cur.update(data["errnotify"])
db.set_setting("errnotify", cur)
return jsonify({"ok": True})
@@ -358,6 +419,28 @@ def api_actions():
return jsonify({"ok": True, "msg": "测试邮件已发送"})
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
if action == "test_error_mail":
try:
notifier.test_error_notify()
return jsonify({"ok": True, "msg": "测试错误通知邮件已发送"})
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
if action == "test_webcapture":
try:
cfg = dict(config.WEBCAPTURE_DEFAULTS)
cfg.update(db.get_all_settings().get("webcapture", {}))
base = (cfg.get("api_url") or "").rstrip("/")
r = requests.post(f"{base}/api/capture", json={"url": "https://example.com",
"action": "text", "wait_time": 800},
timeout=30)
r.raise_for_status()
d = r.json()
if d.get("success"):
return jsonify({"ok": True, "title": d.get("title", ""),
"text": (d.get("text") or "")[:80], "api_url": base})
return jsonify({"ok": False, "error": d.get("error", "接口返回失败")})
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
return jsonify({"ok": False, "error": "unknown action"})
@@ -366,12 +449,30 @@ def not_found(e):
return render_template("404.html"), 404
@app.errorhandler(Exception)
def handle_exception(e):
"""未捕获异常 → 记录系统错误并邮件通知(频率/静默由设置控制),返回 500 页"""
from werkzeug.exceptions import HTTPException
if isinstance(e, HTTPException):
return e
try:
notifier.report_error("系统", "未捕获异常", f"{type(e).__name__}: {e}")
except Exception:
pass
return render_template("500.html"), 500
# ---------------- 启动 ----------------
def main():
db.init_db()
# 确保定制监控机制配置存在(老库升级)
if db.get_setting("custom") is None:
db.set_setting("custom", dict(config.CUSTOM_DEFAULTS))
# 确保网页提取服务 / 错误通知配置存在(老库升级)
if db.get_setting("webcapture") is None:
db.set_setting("webcapture", dict(config.WEBCAPTURE_DEFAULTS))
if db.get_setting("errnotify") is None:
db.set_setting("errnotify", dict(config.ERRNOTIFY_DEFAULTS))
# 首次初始化:写入默认数据源 / 兴趣画像 / 默认设置 / 模拟数据
if db.get_setting("initialized") != 1:
for s in config.DEFAULT_SOURCES:
+19
View File
@@ -54,6 +54,15 @@ CRAWL_DEFAULTS = {
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
}
# ---------------- web-capture-api(网页提取服务,数据源抓取走该接口) ----------------
# 数据源真实网页优先通过 web-capture-api 项目接口抓取(反爬强,可滚动态/选后端)。
# 每个数据源可单独设置获取方式(fetch_method)与抓取参数(capture_params)。
WEBCAPTURE_DEFAULTS = {
"enabled": 1,
"api_url": "http://121.40.164.32:16025", # web-capture-api 地址(本机默认 16025
"timeout": 60, # 单次抓取超时(秒)
}
# ---------------- 邮件通知(默认值,可在设置区修改) ----------------
MAIL_DEFAULTS = {
"smtp_host": "mail.tphai.com",
@@ -88,6 +97,16 @@ CUSTOM_DEFAULTS = {
"custom_max_summary_items": 20, # 定制汇总邮件最多条目数
}
# ---------------- 系统错误邮件通知(频率 + 静默时段) ----------------
ERRNOTIFY_DEFAULTS = {
"enabled": 1, # 是否启用错误邮件通知
"mode": "cooldown", # immediate=立即 / cooldown=按冷却时间聚合 / off=关闭
"cooldown_min": 60, # 通知频率:同类/全部错误两次邮件最小间隔(分钟)
"quiet_enabled": 1, # 是否启用静默时段
"quiet_periods": ["23:00-07:30"], # 静默时段,每段 "HH:MM-HH:MM",支持跨午夜、多段
"max_items": 10, # 单封错误邮件最多错误条数
}
# ---------------- 默认兴趣画像(可在网页修改) ----------------
DEFAULT_KEYWORDS = [
("大模型", 8), ("人工智能", 6), ("AI", 6), ("芯片", 7), ("GPU", 6),
+104 -7
View File
@@ -10,6 +10,7 @@
数据结构与 simulate 保持一致:fetch_source(source) -> list[dict]
title/url/content/summary/published_at/domain/entities/source_id/full_text
"""
import json
import re
import time
from datetime import datetime
@@ -221,15 +222,92 @@ def extract_links(html, base_url, max_n=10):
for c in cands[:max_n]]
# ---------------- web-capture-api 集成(数据源网页抓取) ----------------
def _webcapture_cfg():
cfg = dict(config.WEBCAPTURE_DEFAULTS)
cfg.update(db.get_all_settings().get("webcapture", {}))
return cfg
def _source_capture_params(source):
"""解析数据源的抓取参数(capture_params JSON -> dict"""
raw = source.get("capture_params") or "{}"
try:
p = json.loads(raw)
except Exception:
p = {}
return p if isinstance(p, dict) else {}
def _call_webcapture(url, action, params=None):
"""调用 web-capture-api 抓取网页(html/text)。失败抛异常。
返回: {"success": True, "title": ..., "html"|"text": ...}
"""
cfg = _webcapture_cfg()
base = (cfg.get("api_url") or "").rstrip("/")
if not base:
raise RuntimeError("web-capture-api 地址未配置(设置页可修改)")
payload = {"url": url, "action": action}
p = params or {}
for k in ("wait_time", "scroll_times", "scroll_delay", "full_page", "backend", "viewport"):
if k in p and p[k] not in (None, ""):
payload[k] = p[k]
r = requests.post(f"{base}/api/capture", json=payload,
timeout=int(cfg.get("timeout", 60)))
r.raise_for_status()
data = r.json()
if not data.get("success"):
raise RuntimeError(data.get("error", "web-capture-api 返回失败"))
return data
# ---------------- 按源采集 ----------------
def _now():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _fetch_list_via_direct(url):
"""直接抓取(requests + bs4):返回 (title, page_text, links)"""
per = config.CRAWL_DEFAULTS["per_source_links"]
html = fetch_page(url)
page_title, page_text = clean_html(html, url)
links = extract_links(html, url, max_n=per)
return page_title, page_text, links
def _fetch_list_via_webcapture(url, params):
"""走 web-capture-api 抓取列表页:返回 (title, page_text, links)"""
per = config.CRAWL_DEFAULTS["per_source_links"]
action = (params.get("action") or "html").strip() or "html"
data = _call_webcapture(url, action, params)
title = data.get("title") or url
if action == "text":
# 只取正文,无法提取子链接 → 整页作为一条
return title, (data.get("text") or ""), []
html = data.get("html") or ""
page_text = (data.get("text") or "").strip()
if not page_text and html:
page_text = clean_html(html, url)[1]
links = extract_links(html, url, max_n=per) if html else []
return title, page_text, links
def _fetch_full_via_direct(url):
return clean_html(fetch_page(url), url)[1]
def _fetch_full_via_webcapture(url, params):
data = _call_webcapture(url, "text", params)
return data.get("text") or ""
def fetch_source(source):
"""采集单个数据源 -> list[item]。
example.com 占位源返回模拟数据;真实源抓取失败抛异常(由 fetch_all 捕获并标记 error,不塞模拟数据)。
example.com 占位源返回模拟数据;真实源抓取失败抛异常(由调用方捕获并标记 error)。
fetch_methodauto=优先 web-capture-api、失败回退直接抓取;webcapture=仅 web-capture-api
direct=直接抓取(requests+bs4)。各源可单独配置 capture_params 抓取参数。
"""
url = (source.get("url") or "").strip()
# 模拟源(example.com 占位)→ 用仿真数据填充(补齐 source_id,保证定制监控识别正确)
@@ -238,19 +316,38 @@ def fetch_source(source):
for it in items:
it["source_id"] = source["id"]
return items
method = (source.get("fetch_method") or "auto").strip() or "auto"
per = config.CRAWL_DEFAULTS["per_source_links"]
full = config.CRAWL_DEFAULTS["full_fetch_links"]
html = fetch_page(url)
page_title, page_text = clean_html(html, url)
links = extract_links(html, url, max_n=per)
params = _source_capture_params(source)
# 抓取列表页(标题 + 正文 + 候选链接)
if method == "direct":
page_title, page_text, links = _fetch_list_via_direct(url)
else:
try:
page_title, page_text, links = _fetch_list_via_webcapture(url, params)
except Exception:
if method == "webcapture":
raise
# auto:回退直接抓取
page_title, page_text, links = _fetch_list_via_direct(url)
items = []
for i, lk in enumerate(links[:per]):
full_text = ""
content = lk["summary"]
if i < full and lk["url"]:
try:
h2 = fetch_page(lk["url"])
_, full_text = clean_html(h2, lk["url"])
if method == "direct":
full_text = _fetch_full_via_direct(lk["url"])
else:
try:
full_text = _fetch_full_via_webcapture(lk["url"], params)
except Exception:
if method == "webcapture":
raise
full_text = _fetch_full_via_direct(lk["url"])
if not content or len(content) < len(full_text):
content = full_text
except Exception:
@@ -268,7 +365,7 @@ def fetch_source(source):
"full_text": full_text or "",
})
if not items and page_text:
# 页面本身即正文(如单篇/无链接页)→ 整页作为一条
# 页面本身即正文(如单篇/无链接页/action=text)→ 整页作为一条
items.append({
"title": page_title,
"url": url,
+158 -9
View File
@@ -21,6 +21,8 @@ CREATE TABLE IF NOT EXISTS sources (
kind TEXT DEFAULT 'normal', -- normal 普通(按权重打分) / custom 定制监控(按推送标准LLM判断)
monitor_standard TEXT DEFAULT '', -- 定制监控:推送标准说明
scan_interval_min INTEGER DEFAULT 0, -- 本源采集间隔(分钟),0=跟随所属机制的全局值
fetch_method TEXT DEFAULT 'auto', -- 获取网页方式: auto(优先web-capture-api,失败回退直接抓取)/webcapture/direct
capture_params TEXT DEFAULT '{}', -- web-capture-api 抓取参数(JSON: action/wait_time/scroll_times/scroll_delay/backend/viewport...)
enabled INTEGER DEFAULT 1,
status TEXT DEFAULT 'ok', -- ok / error
last_fetch TEXT DEFAULT '',
@@ -28,6 +30,21 @@ CREATE TABLE IF NOT EXISTS sources (
created_at TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS system_error_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT DEFAULT '', -- 错误来源(采集/分析/通知/汇总/系统...)
message TEXT DEFAULT '', -- 错误信息
detail TEXT DEFAULT '', -- 详情
count INTEGER DEFAULT 1, -- 同类错误累计次数
pending INTEGER DEFAULT 1, -- 1=待通知(尚未发送过邮件) 0=已通知
first_seen TEXT DEFAULT '',
last_seen TEXT DEFAULT '',
last_notified TEXT DEFAULT '',
notified_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_errlog_pending ON system_error_log(pending);
CREATE TABLE IF NOT EXISTS source_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
@@ -144,6 +161,28 @@ def init_db():
created_at TEXT DEFAULT ''
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_snapshots_source ON source_snapshots(source_id, id)")
# 迁移:数据源补充获取方式 / 抓取参数(web-capture-api 集成)
cols = [r["name"] for r in conn.execute("PRAGMA table_info(sources)").fetchall()]
if "fetch_method" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN fetch_method TEXT DEFAULT 'auto'")
if "capture_params" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN capture_params TEXT DEFAULT '{}'")
# 迁移:系统错误日志表
conn.execute("""CREATE TABLE IF NOT EXISTS system_error_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT DEFAULT '',
message TEXT DEFAULT '',
detail TEXT DEFAULT '',
count INTEGER DEFAULT 1,
pending INTEGER DEFAULT 1,
first_seen TEXT DEFAULT '',
last_seen TEXT DEFAULT '',
last_notified TEXT DEFAULT '',
notified_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT ''
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_errlog_pending ON system_error_log(pending)")
conn.commit()
# 迁移:旧库补充 full_text 列(存页面可读正文)
cols = [r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()]
if "full_text" not in cols:
@@ -212,12 +251,15 @@ 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="", scan_interval_min=0):
def add_source(name, type_, url, desc, weight=1.0, kind="normal", monitor_standard="",
scan_interval_min=0, fetch_method="auto", capture_params="{}"):
conn = get_conn()
cur = conn.execute(
"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()),
"INSERT INTO sources(name,type,url,description,weight,kind,monitor_standard,"
"scan_interval_min,fetch_method,capture_params,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(name, type_, url, desc, weight, kind, monitor_standard, scan_interval_min,
fetch_method, capture_params, now_str()),
)
conn.commit()
conn.close()
@@ -226,7 +268,7 @@ 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",
"scan_interval_min", "enabled"}
"scan_interval_min", "fetch_method", "capture_params", "enabled"}
sets, vals = [], []
for k, v in fields.items():
if k in allowed:
@@ -617,7 +659,7 @@ def enabled_providers_except(pid):
return [dict(r) for r in rows]
# ---------------- 通知日志 ----------------
# ---------------- 通知日志(分页 + 筛选) ----------------
def add_log(type_, subject, count, article_ids, status="ok", detail=""):
conn = get_conn()
conn.execute(
@@ -629,10 +671,25 @@ def add_log(type_, subject, count, article_ids, status="ok", detail=""):
conn.close()
def list_logs(limit=50):
def list_logs(page=1, page_size=20, type_=None, status=None, q=None):
"""分页查询通知日志,支持类型/状态/关键词筛选。返回 (total, rows)"""
where, args = "1=1", []
if type_:
where += " AND type=?"
args.append(type_)
if status:
where += " AND status=?"
args.append(status)
if q:
where += " AND (subject LIKE ? OR detail LIKE ?)"
args += [f"%{q}%", f"%{q}%"]
conn = get_conn()
total = conn.execute(
f"SELECT COUNT(*) c FROM notification_log WHERE {where}", args).fetchone()["c"]
rows = conn.execute(
"SELECT * FROM notification_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
f"SELECT * FROM notification_log WHERE {where} ORDER BY id DESC LIMIT ? OFFSET ?",
args + [page_size, (page - 1) * page_size],
).fetchall()
conn.close()
out = []
for r in rows:
@@ -642,7 +699,91 @@ def list_logs(limit=50):
except Exception:
d["article_ids"] = []
out.append(d)
return out
return total, out
def count_logs(type_=None, status=None, q=None):
where, args = "1=1", []
if type_:
where += " AND type=?"
args.append(type_)
if status:
where += " AND status=?"
args.append(status)
if q:
where += " AND (subject LIKE ? OR detail LIKE ?)"
args += [f"%{q}%", f"%{q}%"]
conn = get_conn()
c = conn.execute(f"SELECT COUNT(*) c FROM notification_log WHERE {where}", args).fetchone()["c"]
conn.close()
return c
# ---------------- 系统错误日志(错误邮件通知) ----------------
def add_system_error(source, message, detail=""):
"""记录一条系统错误:同类错误合并计数并置为待通知。返回 error_id"""
conn = get_conn()
now = now_str()
row = conn.execute(
"SELECT id FROM system_error_log WHERE source=? AND message=?",
(source, (message or "")[:300]),
).fetchone()
if row:
conn.execute(
"UPDATE system_error_log SET count=count+1, pending=1, last_seen=?, detail=? WHERE id=?",
(now, (detail or "")[:1000], row["id"]),
)
eid = row["id"]
else:
cur = conn.execute(
"INSERT INTO system_error_log(source,message,detail,count,pending,first_seen,last_seen,created_at) "
"VALUES(?,?,?,1,1,?,?,?)",
(source, (message or "")[:300], (detail or "")[:1000], now, now, now),
)
eid = cur.lastrowid
conn.commit()
conn.close()
return eid
def pending_system_errors(limit=20):
"""待通知的系统错误(未发送过邮件的)"""
conn = get_conn()
rows = conn.execute(
"SELECT * FROM system_error_log WHERE pending=1 ORDER BY last_seen DESC LIMIT ?",
(limit,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_system_errors(limit=100):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM system_error_log ORDER BY last_seen DESC LIMIT ?", (limit,)).fetchall()
conn.close()
return [dict(r) for r in rows]
def mark_errors_notified(eids, t):
"""标记一批错误已邮件通知"""
conn = get_conn()
for eid in eids:
conn.execute(
"UPDATE system_error_log SET pending=0, last_notified=?, notified_count=notified_count+1 WHERE id=?",
(t, eid),
)
conn.commit()
conn.close()
def get_err_last_send():
v = get_setting("err_last_send", None)
return v
def set_err_last_send(t):
set_setting("err_last_send", t)
def clear_old_articles(days=30):
@@ -651,3 +792,11 @@ def clear_old_articles(days=30):
"DELETE FROM articles WHERE collected_at < datetime('now','localtime','-%d days')" % days)
conn.commit()
conn.close()
def clear_old_errors(days=30):
conn = get_conn()
conn.execute(
"DELETE FROM system_error_log WHERE last_seen < datetime('now','localtime','-%d days')" % days)
conn.commit()
conn.close()
+125
View File
@@ -4,6 +4,7 @@
支持 plain / starttls / ssl 三种 SMTP 模式,发送实时重要资讯与每日汇总。
"""
import smtplib
from datetime import datetime
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -43,6 +44,130 @@ def send_email(subject, html, to=None):
return True
# ============ 系统错误邮件通知(频率 + 静默时段) ============
def _esc(s):
return str(s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def _errnotify_cfg():
cfg = dict(config.ERRNOTIFY_DEFAULTS)
cfg.update(db.get_all_settings().get("errnotify", {}))
return cfg
def _in_quiet_period(now=None):
"""是否处于静默时段。支持多段、跨午夜(如 23:00-07:30)。"""
cfg = _errnotify_cfg()
if not cfg.get("quiet_enabled"):
return False
now = now or datetime.now()
cur_min = now.hour * 60 + now.minute
for p in cfg.get("quiet_periods") or []:
p = (p or "").strip()
if not p or "-" not in p:
continue
try:
s, e = p.split("-")
sh, sm = map(int, s.strip().split(":"))
eh, em = map(int, e.strip().split(":"))
except Exception:
continue
s_min, e_min = sh * 60 + sm, eh * 60 + em
if s_min <= e_min:
if s_min <= cur_min < e_min:
return True
else: # 跨午夜
if cur_min >= s_min or cur_min < e_min:
return True
return False
def _send_error_digest(errors):
"""发送一封错误通知邮件(受调用方冷却/静默控制),并记录通知日志"""
rows = "".join(
f"<div style='border:1px solid #fecaca;border-left:4px solid #dc2626;border-radius:6px;"
f"padding:10px 14px;margin:8px 0;'>"
f"<div style='font-weight:bold;color:#b91c1c;'>{_esc(e.get('source', ''))} · 发生 {e.get('count', 1)} 次</div>"
f"<div style='font-size:13px;color:#374151;margin-top:2px;'>{_esc(e.get('message', ''))}</div>"
f"<div style='font-size:12px;color:#6b7280;'>{_esc(e.get('detail', ''))}</div>"
f"<div style='font-size:11px;color:#9ca3af;'>最近: {_esc(e.get('last_seen', ''))} · "
f"首次: {_esc(e.get('first_seen', ''))}</div></div>"
for e in errors
)
subject = f"⚠️ 系统异常通知 · {len(errors)} 类错误"
html = _html_wrap(
"系统运行异常提醒",
f"<p style='color:#374151;font-size:13px;'>新闻智能跟踪系统检测到以下异常,请及时处理:</p>{rows}",
)
send_email(subject, html)
db.add_log("error", subject, len(errors), [], status="ok", detail="系统错误邮件通知")
def _try_send_pending():
"""按通知策略发送待通知错误(受静默时段 + 冷却频率控制)。返回发送条数。"""
cfg = _errnotify_cfg()
if not cfg.get("enabled") or cfg.get("mode") == "off":
return 0
if _in_quiet_period():
return 0 # 静默时段不发,等待调度器在静默结束后 flush
# 冷却频率:两次错误邮件最小间隔
last_sent = db.get_err_last_send()
if last_sent:
try:
lt = datetime.strptime(last_sent, "%Y-%m-%d %H:%M:%S")
cooldown = max(0, int(cfg.get("cooldown_min", 60) or 0))
if cooldown > 0 and (datetime.now() - lt).total_seconds() < cooldown * 60:
return 0 # 冷却中
except Exception:
pass
errors = db.pending_system_errors(limit=int(cfg.get("max_items", 10) or 10))
if not errors:
return 0
try:
_send_error_digest(errors)
except Exception:
# 邮件发送失败:保留 pending,下次重试
return 0
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db.mark_errors_notified([e["id"] for e in errors], now)
db.set_err_last_send(now)
return len(errors)
def report_error(source, message, detail=""):
"""记录一条系统错误;immediate 模式下尝试立即发送(受冷却/静默控制)。"""
try:
db.add_system_error(source, message, detail)
except Exception:
return None
cfg = _errnotify_cfg()
if not cfg.get("enabled") or cfg.get("mode") not in ("immediate", "cooldown"):
return None
if cfg.get("mode") == "immediate":
try:
_try_send_pending()
except Exception:
pass
return True
def flush_pending_errors():
"""后台调度器定时调用:把待通知错误按策略发送(冷却 + 静默控制)"""
return _try_send_pending()
def test_error_notify():
"""发送一封测试错误邮件(无视冷却/静默,用于设置页测试按钮)"""
_send_error_digest([{
"source": "测试", "message": "这是一封测试错误通知邮件",
"detail": "如果你收到了这封邮件,说明系统错误邮件通知链路正常。",
"count": 1, "first_seen": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"last_seen": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}])
return True
def _score_color(score):
if score >= 80:
return "#e74c3c"
+17 -4
View File
@@ -75,6 +75,7 @@ def collect_once(force=False):
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])
notifier.report_error("采集", f"数据源「{s['name']}」采集失败", str(e)[:300])
for it in items:
if db.article_exists(it["url"]):
continue
@@ -86,8 +87,8 @@ def collect_once(force=False):
# 实时通知(新闻重要资讯 + 定制监控命中)
try:
send_realtime_if_needed()
except Exception:
pass
except Exception as e:
notifier.report_error("实时通知", "实时重要资讯推送异常", str(e)[:300])
return added
@@ -163,6 +164,7 @@ def _maybe_daily(now, last_day, time_key, send_fn, kind):
last_day = (day_key,)
except Exception as e:
db.add_log(kind, f"{time_key} 汇总异常", 0, [], status="error", detail=str(e))
notifier.report_error("汇总", f"{time_key} 汇总发送异常", str(e)[:300])
return last_day
@@ -176,16 +178,27 @@ def scheduler_loop(stop_event):
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
except Exception as e:
notifier.report_error("汇总", "定时汇总检查异常", str(e)[:300])
# 采集到期数据源(每源独立周期)
try:
collect_once()
except Exception as e:
db.add_log("realtime", "采集异常", 0, [], status="error", detail=str(e))
notifier.report_error("采集", "定时采集异常", str(e)[:300])
# 实时通知(LLM 分析完成后推送)
try:
send_realtime_if_needed()
except Exception as e:
notifier.report_error("实时通知", "实时推送检查异常", str(e)[:300])
# 系统错误通知(冷却频率 + 静默时段控制,静默结束后自动补发)
try:
notifier.flush_pending_errors()
except Exception:
pass
# 定期清理过期错误日志(每天一次)
try:
db.clear_old_errors(days=30)
except Exception:
pass
# 30s 轮询粒度,兼顾每源自定义的短周期(如 5 分钟)
+2
View File
@@ -31,6 +31,8 @@ a { color:#2563eb; text-decoration:none; }
.card { background:#fff; border-radius:12px; padding:18px 20px; margin-bottom:18px; box-shadow:0 1px 3px rgba(0,0,0,.06); }
.card h3 { font-size:15px; margin-bottom:12px; color:#111827; }
.card h3 .more { float:right; font-size:12px; font-weight:normal; color:#2563eb; margin-top:3px; }
.card h3 .more:hover { color:#1d4ed8; text-decoration:underline; }
.stat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
.stat { background:#fff; border-radius:12px; padding:18px; text-align:center; box-shadow:0 1px 3px rgba(0,0,0,.06); }
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}系统异常 - 新闻智能跟踪{% endblock %}
{% block content %}
<div style="text-align:center;padding:80px 20px;">
<div style="font-size:64px;">⚠️</div>
<h1 style="margin:12px 0;">系统处理出错</h1>
<p class="muted">请求处理时发生异常,已自动记录并邮件通知管理员。</p>
<a class="btn" href="/dashboard" style="margin-top:16px;">← 返回仪表盘</a>
</div>
{% endblock %}
+2 -2
View File
@@ -21,7 +21,7 @@
<div class="two-col">
<section class="card">
<h3>🔥 重要资讯 TOP</h3>
<h3>🔥 重要资讯 TOP <a class="more" href="/news?important=1">更多 </a></h3>
{% for a in important %}
<div class="item">
<a class="item-title" href="/news/{{ a.id }}">{{ a.title }}</a>
@@ -50,7 +50,7 @@
</div>
<section class="card">
<h3>🕒 最新收录</h3>
<h3>🕒 最新收录 <a class="more" href="/news">更多 </a></h3>
{% for a in latest %}
<div class="item">
<a class="item-title" href="/news/{{ a.id }}">{{ a.title }}</a>
+46 -6
View File
@@ -3,9 +3,31 @@
{% block title %}通知日志 - 新闻智能跟踪{% endblock %}
{% block content %}
<div class="page-head">
<h1>✉️ 通知日志</h1>
<button class="btn" onclick="testMail()">📮 发送测试邮件</button>
<h1>✉️ 通知日志 <span class="muted">共 {{ total }} 条</span></h1>
<div class="actions">
<button class="btn" onclick="testMail()">📮 发送测试邮件</button>
<button class="btn" onclick="testErrorMail()">⚠️ 测试错误通知</button>
</div>
</div>
<form class="filters" method="get" action="/logs">
<select name="type">
<option value="">全部类型</option>
<option value="realtime" {{ 'selected' if f_type=='realtime' }}>🔥 实时重要资讯</option>
<option value="summary" {{ 'selected' if f_type=='summary' }}>📰 新闻汇总</option>
<option value="custom_summary" {{ 'selected' if f_type=='custom_summary' }}>🎯 定制监控汇总</option>
<option value="error" {{ 'selected' if f_type=='error' }}>⚠️ 系统错误</option>
</select>
<select name="status">
<option value="">全部状态</option>
<option value="ok" {{ 'selected' if f_status=='ok' }}>✅ 成功</option>
<option value="error" {{ 'selected' if f_status=='error' }}>❌ 失败</option>
</select>
<input type="text" name="q" value="{{ q }}" placeholder="搜索主题 / 详情...">
<button class="btn" type="submit">筛选</button>
<a class="btn" href="/logs">重置</a>
</form>
<div class="card">
<table>
<thead><tr><th>ID</th><th>类型</th><th>主题</th><th>条数</th><th>状态</th><th>发送时间</th></tr></thead>
@@ -13,18 +35,32 @@
{% for l in logs %}
<tr>
<td>{{ l.id }}</td>
<td>{% if l.type=='realtime' %}<span class="tag hot">实时</span>{% else %}<span class="tag ok">汇总</span>{% endif %}</td>
<td>{{ l.subject }}</td>
<td>
{% if l.type=='realtime' %}<span class="tag hot">🔥 实时</span>
{% elif l.type=='summary' %}<span class="tag ok">📰 新闻汇总</span>
{% elif l.type=='custom_summary' %}<span class="tag">🎯 定制汇总</span>
{% elif l.type=='error' %}<span class="tag err">⚠️ 系统错误</span>
{% else %}<span class="tag">{{ l.type }}</span>{% endif %}
</td>
<td>{{ l.subject }}
{% if l.detail %}<div class="muted small">{{ l.detail }}</div>{% endif %}
</td>
<td>{{ l.count }}</td>
<td>{% if l.status=='ok' %}<span class="tag ok">成功</span>{% else %}<span class="tag err">失败</span>{% endif %}</td>
<td>{{ l.sent_at }}</td>
<td class="small">{{ l.sent_at }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">暂无通知记录</td></tr>
<tr><td colspan="6" class="empty">暂无匹配的通知记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pager">
{% if page > 1 %}<a class="btn" href="/logs?page={{ page-1 }}&type={{ f_type }}&status={{ f_status }}&q={{ q }}">上一页</a>{% endif %}
<span class="muted">第 {{ page }} / {{ pages }} 页</span>
{% if page < pages %}<a class="btn" href="/logs?page={{ page+1 }}&type={{ f_type }}&status={{ f_status }}&q={{ q }}">下一页</a>{% endif %}
</div>
{% endblock %}
{% block script %}
<script>
@@ -32,5 +68,9 @@ async function testMail(){
const r = await API.json('/api/actions', {action:'test_mail'});
toast(r.ok ? '✅ 测试邮件已发送' : ('❌ ' + r.error), r.ok);
}
async function testErrorMail(){
const r = await API.json('/api/actions', {action:'test_error_mail'});
toast(r.ok ? '✅ 测试错误通知邮件已发送' : ('❌ ' + r.error), r.ok);
}
</script>
{% endblock %}
+96 -2
View File
@@ -98,7 +98,72 @@
</section>
<section class="card">
<h3>🤖 大模型接口
<h3>🌐 网页提取服务(web-capture-api
<span class="muted small">(数据源真实网页默认通过该服务抓取,每源可单独选获取方式与参数)</span>
</h3>
<div class="setting-row">
<label>启用 web-capture-api 抓取</label>
<input type="checkbox" id="w_enabled" {{ 'checked' if auto.webcapture.enabled }}>
</div>
<div class="setting-row">
<label>API 地址</label>
<input id="w_api_url" value="{{ auto.webcapture.api_url }}" placeholder="http://121.40.164.32:16025">
</div>
<div class="setting-row">
<label>抓取超时(秒)</label>
<input type="number" id="w_timeout" value="{{ auto.webcapture.timeout }}">
</div>
<div class="setting-row">
<label>连通性测试</label>
<span style="display:flex;gap:8px;align-items:center;">
<button class="btn mini" type="button" onclick="testWebcapture()">🧪 测试接口</button>
<span id="wc_test_result" class="muted small"></span>
</span>
</div>
</section>
<section class="card">
<h3>⚠️ 系统错误邮件通知
<span class="muted small">(采集/分析/通知/汇总/系统异常自动邮件通知,可设通知频率与静默时段)</span>
</h3>
<div class="setting-row">
<label>启用错误邮件通知</label>
<input type="checkbox" id="e_enabled" {{ 'checked' if auto.errnotify.enabled }}>
</div>
<div class="setting-row">
<label>通知方式</label>
<select id="e_mode">
<option value="immediate" {{ 'selected' if auto.errnotify.mode=='immediate' }}>⚡ 立即(出现即尝试发,仍受冷却限制)</option>
<option value="cooldown" {{ 'selected' if auto.errnotify.mode=='cooldown' }}>⏱ 按冷却聚合(到冷却时间集中发一封)</option>
<option value="off" {{ 'selected' if auto.errnotify.mode=='off' }}>🚫 关闭(仅记录不发送)</option>
</select>
</div>
<div class="setting-row">
<label>通知频率(冷却分钟)<span class="muted small">— 两次错误邮件最小间隔</span></label>
<input type="number" id="e_cooldown_min" value="{{ auto.errnotify.cooldown_min }}">
</div>
<div class="setting-row">
<label>单封最多错误条数</label>
<input type="number" id="e_max_items" value="{{ auto.errnotify.max_items }}">
</div>
<div class="setting-row">
<label>启用静默时段</label>
<input type="checkbox" id="e_quiet_enabled" {{ 'checked' if auto.errnotify.quiet_enabled }}>
</div>
<div style="margin-top:10px;">
<label style="font-size:12px;color:#6b7280;">静默时段(每行一段 <b>HH:MM-HH:MM</b>,支持跨午夜、多段;静默期不发送,结束后自动补发)</label>
<textarea id="e_quiet_periods" rows="3" style="margin-top:4px;">{{ '\n'.join(auto.errnotify.quiet_periods) }}</textarea>
</div>
<div class="setting-row">
<label>测试</label>
<span style="display:flex;gap:8px;align-items:center;">
<button class="btn mini" type="button" onclick="testErrorMail()">📨 发一封测试错误通知</button>
<span id="e_test_result" class="muted small"></span>
</span>
</div>
</section>
<section class="card">
<span class="muted small">(切换后即时生效,智能分析将使用当前接口;失败自动切换下一个可用接口)</span>
</h3>
<div class="form-row" style="margin-bottom:12px;">
@@ -170,10 +235,39 @@ async function saveAll(){
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 });
const webcapture = {
enabled: document.getElementById('w_enabled').checked ? 1 : 0,
api_url: G('w_api_url').trim() || 'http://121.40.164.32:16025',
timeout: parseInt(G('w_timeout')) || 60,
};
const errnotify = {
enabled: document.getElementById('e_enabled').checked ? 1 : 0,
mode: G('e_mode'),
cooldown_min: parseInt(G('e_cooldown_min')) || 60,
max_items: parseInt(G('e_max_items')) || 10,
quiet_enabled: document.getElementById('e_quiet_enabled').checked ? 1 : 0,
quiet_periods: G('e_quiet_periods').split(/[\n,;,;]/).map(s=>s.trim()).filter(Boolean),
};
const r = await API.json('/api/settings', { ...auto, mail, custom, webcapture, errnotify });
toast(r.ok ? '✅ 设置已保存' : '❌ 保存失败', r.ok);
}
async function testWebcapture(){
const el = document.getElementById('wc_test_result');
el.textContent = '⏳ 测试中...';
const r = await API.json('/api/actions', {action:'test_webcapture'});
if (r.ok) el.textContent = `${r.api_url} 正常,抓取到:${r.title}${(r.text||'').trim()}`;
else el.textContent = '❌ ' + (r.error || '测试失败');
}
async function testErrorMail(){
const el = document.getElementById('e_test_result');
el.textContent = '⏳ 发送中...';
const r = await API.json('/api/actions', {action:'test_error_mail'});
if (r.ok) el.textContent = '✅ 测试错误通知邮件已发送';
else el.textContent = '❌ ' + (r.error || '发送失败');
}
async function addProvider(){
const r = await API.json('/api/llm', {
action:'add',
+78 -5
View File
@@ -9,13 +9,14 @@
<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><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><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 '' }}" data-interval="{{ s.scan_interval_min or 0 }}">
<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 }}" data-fetch="{{ s.fetch_method or 'auto' }}" data-capture="{{ s.capture_params 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>{% if s.fetch_method == 'direct' %}<span class="tag">⚡ 直接抓取</span>{% elif s.fetch_method == 'webcapture' %}<span class="tag">🌐 web-capture</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>
@@ -33,8 +34,9 @@
</tbody>
</table>
<div class="muted small" style="margin-top:8px;">
💡 真实 URL 源自动抓取页面并清洗可读正文入库example.com 等占位地址走模拟数据。<br>
💡 真实 URL 源默认通过 web-capture-api 网页提取服务抓取(可每源改获取方式与参数)example.com 等占位地址走模拟数据。<br>
⏱ 采集周期:0=跟随所属机制全局值(普通源=设置页采集间隔,定制源=定制监控间隔),填分钟数则本源自定义。<br>
🌐 获取方式:<b>自动</b>=优先 web-capture-api、失败回退直接抓取;<b>web-capture</b>=仅走网页提取服务;<b>直接抓取</b>=requests+正文清洗。设置页可改 web-capture-api 地址。<br>
🎯 <b>定制监控</b>:与新闻监控独立,无权重,按「推送标准」由大模型判断是否推送,命中实时推送 + 单独汇总。
</div>
</div>
@@ -59,6 +61,35 @@
<label>采集周期(分钟)<span class="muted small">— 0 表示跟随所属机制的全局间隔</span></label>
<input id="m_interval" type="number" min="0" step="1" value="0" placeholder="0=跟随全局(普通源30 / 定制源15)">
<label>获取网页方式<span class="muted small">— 真实 URL 网页通过 web-capture-api 抓取(设置页可改接口地址)</span></label>
<select id="m_fetch" onchange="onFetchChange()">
<option value="auto">🌐 自动(优先 web-capture-api,失败回退直接抓取)</option>
<option value="webcapture">🌐 web-capture-api(网页提取服务,反爬强)</option>
<option value="direct">⚡ 直接抓取(requests + 正文清洗)</option>
</select>
<div id="m_capture_row">
<label>抓取动作<span class="muted small">— html 提取资讯链接;text 整页作为一条资讯</span></label>
<select id="m_c_action">
<option value="html">html(提取链接)</option>
<option value="text">text(直接取正文)</option>
</select>
<div class="form-row" style="justify-content:flex-start;margin-top:6px;">
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;white-space:nowrap;">等待加载(ms)
<input id="m_c_wait" type="number" value="2000" style="width:90px;"></label>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;white-space:nowrap;">滚动次数
<input id="m_c_scroll" type="number" value="0" style="width:70px;"></label>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;white-space:nowrap;">后端
<select id="m_c_backend" style="width:130px;">
<option value="auto">auto</option>
<option value="agent-browser">agent-browser</option>
<option value="playwright">playwright</option>
</select></label>
</div>
<label>高级参数(JSON,可留空)</label>
<textarea id="m_c_extra" rows="2" placeholder='{"scroll_delay":1000,"full_page":false,"viewport":{"width":1280,"height":800}}'></textarea>
</div>
<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">
@@ -130,13 +161,28 @@ function openModal(id){
setV('m_kind', row.dataset.kind || 'normal');
setV('m_standard', row.dataset.standard || '');
setV('m_interval', row.dataset.interval || '0');
const cap = parseCapture(row.dataset.capture);
setV('m_fetch', row.dataset.fetch || 'auto');
setV('m_c_action', cap.action || 'html');
setV('m_c_wait', cap.wait_time != null ? cap.wait_time : '2000');
setV('m_c_scroll', cap.scroll_times != null ? cap.scroll_times : '0');
setV('m_c_backend', cap.backend || 'auto');
const extra = {...cap};
['action','wait_time','scroll_times','backend'].forEach(k => delete extra[k]);
setV('m_c_extra', Object.keys(extra).length ? JSON.stringify(extra) : '');
} else {
['m_name','m_type','m_url','m_desc','m_standard'].forEach(k=>setV(k,''));
['m_name','m_type','m_url','m_desc','m_standard','m_c_extra'].forEach(k=>setV(k,''));
setV('m_weight', '0.8');
setV('m_kind', 'normal');
setV('m_interval', '0');
setV('m_fetch', 'auto');
setV('m_c_action', 'html');
setV('m_c_wait', '2000');
setV('m_c_scroll', '0');
setV('m_c_backend', 'auto');
}
onKindChange();
onFetchChange();
document.getElementById('sourceModal').style.display = 'flex';
}
function closeModal(){ document.getElementById('sourceModal').style.display = 'none'; editId = null; }
@@ -147,14 +193,41 @@ function onKindChange(){
document.getElementById('m_standard_row').style.display = custom ? 'block' : 'none';
}
function onFetchChange(){
document.getElementById('m_capture_row').style.display =
getV('m_fetch') === 'direct' ? 'none' : 'block';
}
function parseCapture(s){
try { const o = JSON.parse(s || '{}'); return o && typeof o === 'object' ? o : {}; } catch(e){ return {}; }
}
async function saveSource(){
const kind = getV('m_kind');
let captureParams = '{}';
if (getV('m_fetch') !== 'direct'){
const base = {
action: getV('m_c_action') || 'html',
wait_time: parseInt(getV('m_c_wait')) || 2000,
scroll_times: parseInt(getV('m_c_scroll')) || 0,
backend: getV('m_c_backend') || 'auto'
};
let adv = {};
const extraTxt = getV('m_c_extra').trim();
if (extraTxt){
try { adv = JSON.parse(extraTxt); }
catch(e){ toast('高级参数 JSON 格式错误', false); return; }
}
captureParams = JSON.stringify({ ...base, ...adv });
}
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
scan_interval_min: parseInt(getV('m_interval')) || 0,
fetch_method: getV('m_fetch'),
capture_params: captureParams
};
if (!body.name){ toast('请填写名称', false); return; }
if (kind === 'custom' && !body.monitor_standard){ toast('请填写推送标准', false); return; }