From e8c8889460880862453749b8873e734c73fab0f6 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Sun, 30 Aug 2026 19:03:50 +0800 Subject: [PATCH] =?UTF-8?q?v1.4.0:=20=E9=80=9A=E7=9F=A5=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=88=86=E9=A1=B5+=E7=AD=9B=E9=80=89=20/=20=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98TOP10+=E6=9B=B4=E5=A4=9A=E9=93=BE=E6=8E=A5=20/=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E8=B5=B0web-capture-api=E6=8A=93?= =?UTF-8?q?=E5=8F=96(=E8=8E=B7=E5=8F=96=E6=96=B9=E5=BC=8F=E4=B8=8E?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=8F=AF=E7=BC=96=E8=BE=91)=20/=20=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=94=99=E8=AF=AF=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5?= =?UTF-8?q?(=E9=A2=91=E7=8E=87+=E9=9D=99=E9=BB=98=E6=97=B6=E6=AE=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 47 +++++++---- analysis.py | 17 ++++ app.py | 113 ++++++++++++++++++++++++-- config.py | 19 +++++ crawler.py | 111 ++++++++++++++++++++++++-- db.py | 167 ++++++++++++++++++++++++++++++++++++--- notifier.py | 125 +++++++++++++++++++++++++++++ scheduler.py | 21 ++++- static/style.css | 2 + templates/500.html | 10 +++ templates/dashboard.html | 4 +- templates/logs.html | 52 ++++++++++-- templates/settings.html | 98 ++++++++++++++++++++++- templates/sources.html | 83 +++++++++++++++++-- 14 files changed, 812 insertions(+), 57 deletions(-) create mode 100644 templates/500.html diff --git a/README.md b/README.md index e48a1cd..34bc06e 100644 --- a/README.md +++ b/README.md @@ -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/` | 资讯详情:规则分/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 抓取+获取方式与参数可编辑 / 系统错误邮件通知:频率+静默时段) diff --git a/analysis.py b/analysis.py index 9a4becb..47234e6 100644 --- a/analysis.py +++ b/analysis.py @@ -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 diff --git a/app.py b/app.py index 8cf0ca7..842eb2c 100644 --- a/app.py +++ b/app.py @@ -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: diff --git a/config.py b/config.py index 8b73bc3..dd50d6d 100644 --- a/config.py +++ b/config.py @@ -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), diff --git a/crawler.py b/crawler.py index 328c31a..d3d1800 100644 --- a/crawler.py +++ b/crawler.py @@ -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, diff --git a/db.py b/db.py index 62abc98..ac7af2f 100644 --- a/db.py +++ b/db.py @@ -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() diff --git a/notifier.py b/notifier.py index baa1460..05e4b5f 100644 --- a/notifier.py +++ b/notifier.py @@ -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"
" + f"
{_esc(e.get('source', ''))} · 发生 {e.get('count', 1)} 次
" + f"
{_esc(e.get('message', ''))}
" + f"
{_esc(e.get('detail', ''))}
" + f"
最近: {_esc(e.get('last_seen', ''))} · " + f"首次: {_esc(e.get('first_seen', ''))}
" + for e in errors + ) + subject = f"⚠️ 系统异常通知 · {len(errors)} 类错误" + html = _html_wrap( + "系统运行异常提醒", + f"

新闻智能跟踪系统检测到以下异常,请及时处理:

{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" diff --git a/scheduler.py b/scheduler.py index d79929b..453e265 100644 --- a/scheduler.py +++ b/scheduler.py @@ -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 分钟) diff --git a/static/style.css b/static/style.css index be1d98b..985e7d4 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..90829ca --- /dev/null +++ b/templates/500.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}系统异常 - 新闻智能跟踪{% endblock %} +{% block content %} +
+
⚠️
+

系统处理出错

+

请求处理时发生异常,已自动记录并邮件通知管理员。

+ ← 返回仪表盘 +
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index 6f985cd..fd7a2f9 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -21,7 +21,7 @@
-

🔥 重要资讯 TOP

+

🔥 重要资讯 TOP 更多 ›

{% for a in important %}
{{ a.title }} @@ -50,7 +50,7 @@
-

🕒 最新收录

+

🕒 最新收录 更多 ›

{% for a in latest %}
{{ a.title }} diff --git a/templates/logs.html b/templates/logs.html index 1d31210..9a1a8c9 100644 --- a/templates/logs.html +++ b/templates/logs.html @@ -3,9 +3,31 @@ {% block title %}通知日志 - 新闻智能跟踪{% endblock %} {% block content %}
-

✉️ 通知日志

- +

✉️ 通知日志 共 {{ total }} 条

+
+ + +
+ +
+ + + + + 重置 +
+
@@ -13,18 +35,32 @@ {% for l in logs %} - - + + - + {% else %} - + {% endfor %}
ID类型主题条数状态发送时间
{{ l.id }}{% if l.type=='realtime' %}实时{% else %}汇总{% endif %}{{ l.subject }} + {% if l.type=='realtime' %}🔥 实时 + {% elif l.type=='summary' %}📰 新闻汇总 + {% elif l.type=='custom_summary' %}🎯 定制汇总 + {% elif l.type=='error' %}⚠️ 系统错误 + {% else %}{{ l.type }}{% endif %} + {{ l.subject }} + {% if l.detail %}
{{ l.detail }}
{% endif %} +
{{ l.count }} {% if l.status=='ok' %}成功{% else %}失败{% endif %}{{ l.sent_at }}{{ l.sent_at }}
暂无通知记录
暂无匹配的通知记录
+ +
+ {% if page > 1 %}上一页{% endif %} + 第 {{ page }} / {{ pages }} 页 + {% if page < pages %}下一页{% endif %} +
{% endblock %} {% block script %} {% endblock %} diff --git a/templates/settings.html b/templates/settings.html index 148f8c3..3bff579 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -98,7 +98,72 @@
-

🤖 大模型接口 +

🌐 网页提取服务(web-capture-api) + (数据源真实网页默认通过该服务抓取,每源可单独选获取方式与参数) +

+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ +
+

⚠️ 系统错误邮件通知 + (采集/分析/通知/汇总/系统异常自动邮件通知,可设通知频率与静默时段) +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ +
(切换后即时生效,智能分析将使用当前接口;失败自动切换下一个可用接口)
@@ -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', diff --git a/templates/sources.html b/templates/sources.html index 74264b5..5dc7d68 100644 --- a/templates/sources.html +++ b/templates/sources.html @@ -9,13 +9,14 @@
- + {% for s in sources %} - + + @@ -33,8 +34,9 @@
ID名称监控方式类型权重采集周期状态最近采样条数操作
ID名称监控方式获取方式类型权重采集周期状态最近采样条数操作
{{ s.id }} {{ s.name }}
{{ s.description }}
{{ s.url }}
{% if s.kind == 'custom' %}🎯 定制监控{% else %}普通{% endif %}{% if s.fetch_method == 'direct' %}⚡ 直接抓取{% elif s.fetch_method == 'webcapture' %}🌐 web-capture{% else %}🌐 自动{% endif %} {{ s.type }} {% if s.kind == 'custom' %}{% else %}{{ s.weight }}{% endif %} {% if s.scan_interval_min %}{{ s.scan_interval_min }}分{% else %}默认{% endif %}
- 💡 真实 URL 源自动抓取页面并清洗可读正文入库;example.com 等占位地址走模拟数据。
+ 💡 真实 URL 源默认通过 web-capture-api 网页提取服务抓取(可每源改获取方式与参数);example.com 等占位地址走模拟数据。
⏱ 采集周期:0=跟随所属机制全局值(普通源=设置页采集间隔,定制源=定制监控间隔),填分钟数则本源自定义。
+ 🌐 获取方式:自动=优先 web-capture-api、失败回退直接抓取;web-capture=仅走网页提取服务;直接抓取=requests+正文清洗。设置页可改 web-capture-api 地址。
🎯 定制监控:与新闻监控独立,无权重,按「推送标准」由大模型判断是否推送,命中实时推送 + 单独汇总。
@@ -59,6 +61,35 @@ + + + +
+ + +
+ + + +
+ + +
+
@@ -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; }