v1.4.0: 通知日志分页+筛选 / 仪表盘TOP10+更多链接 / 数据源走web-capture-api抓取(获取方式与参数可编辑) / 系统错误邮件通知(频率+静默时段)
This commit is contained in:
@@ -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 + Flask(threaded)
|
||||
- **网页采集**:requests + BeautifulSoup/lxml(readability 风格正文清洗)
|
||||
- **网页采集**:优先 **web-capture-api**(agent-browser / Playwright 双后端,反爬强);直接抓取回退用 requests + BeautifulSoup/lxml(readability 风格正文清洗)
|
||||
- **数据库**:SQLite(原生 sqlite3,WAL 模式,无 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 抓取参数 JSON;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/enabled;active=当前激活)
|
||||
- **notification_log**:邮件通知日志
|
||||
- **settings**:KV 配置(`auto` / `mail` 为 JSON)
|
||||
- **notification_log**:邮件通知日志(type:realtime / 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
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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_method:auto=优先 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,
|
||||
|
||||
@@ -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
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
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
@@ -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 分钟)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user