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

This commit is contained in:
2026-08-30 19:03:50 +08:00
parent de08530958
commit e8c8889460
14 changed files with 812 additions and 57 deletions
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}系统异常 - 新闻智能跟踪{% endblock %}
{% block content %}
<div style="text-align:center;padding:80px 20px;">
<div style="font-size:64px;">⚠️</div>
<h1 style="margin:12px 0;">系统处理出错</h1>
<p class="muted">请求处理时发生异常,已自动记录并邮件通知管理员。</p>
<a class="btn" href="/dashboard" style="margin-top:16px;">← 返回仪表盘</a>
</div>
{% endblock %}
+2 -2
View File
@@ -21,7 +21,7 @@
<div class="two-col">
<section class="card">
<h3>🔥 重要资讯 TOP</h3>
<h3>🔥 重要资讯 TOP <a class="more" href="/news?important=1">更多 </a></h3>
{% for a in important %}
<div class="item">
<a class="item-title" href="/news/{{ a.id }}">{{ a.title }}</a>
@@ -50,7 +50,7 @@
</div>
<section class="card">
<h3>🕒 最新收录</h3>
<h3>🕒 最新收录 <a class="more" href="/news">更多 </a></h3>
{% for a in latest %}
<div class="item">
<a class="item-title" href="/news/{{ a.id }}">{{ a.title }}</a>
+46 -6
View File
@@ -3,9 +3,31 @@
{% block title %}通知日志 - 新闻智能跟踪{% endblock %}
{% block content %}
<div class="page-head">
<h1>✉️ 通知日志</h1>
<button class="btn" onclick="testMail()">📮 发送测试邮件</button>
<h1>✉️ 通知日志 <span class="muted">共 {{ total }} 条</span></h1>
<div class="actions">
<button class="btn" onclick="testMail()">📮 发送测试邮件</button>
<button class="btn" onclick="testErrorMail()">⚠️ 测试错误通知</button>
</div>
</div>
<form class="filters" method="get" action="/logs">
<select name="type">
<option value="">全部类型</option>
<option value="realtime" {{ 'selected' if f_type=='realtime' }}>🔥 实时重要资讯</option>
<option value="summary" {{ 'selected' if f_type=='summary' }}>📰 新闻汇总</option>
<option value="custom_summary" {{ 'selected' if f_type=='custom_summary' }}>🎯 定制监控汇总</option>
<option value="error" {{ 'selected' if f_type=='error' }}>⚠️ 系统错误</option>
</select>
<select name="status">
<option value="">全部状态</option>
<option value="ok" {{ 'selected' if f_status=='ok' }}>✅ 成功</option>
<option value="error" {{ 'selected' if f_status=='error' }}>❌ 失败</option>
</select>
<input type="text" name="q" value="{{ q }}" placeholder="搜索主题 / 详情...">
<button class="btn" type="submit">筛选</button>
<a class="btn" href="/logs">重置</a>
</form>
<div class="card">
<table>
<thead><tr><th>ID</th><th>类型</th><th>主题</th><th>条数</th><th>状态</th><th>发送时间</th></tr></thead>
@@ -13,18 +35,32 @@
{% for l in logs %}
<tr>
<td>{{ l.id }}</td>
<td>{% if l.type=='realtime' %}<span class="tag hot">实时</span>{% else %}<span class="tag ok">汇总</span>{% endif %}</td>
<td>{{ l.subject }}</td>
<td>
{% if l.type=='realtime' %}<span class="tag hot">🔥 实时</span>
{% elif l.type=='summary' %}<span class="tag ok">📰 新闻汇总</span>
{% elif l.type=='custom_summary' %}<span class="tag">🎯 定制汇总</span>
{% elif l.type=='error' %}<span class="tag err">⚠️ 系统错误</span>
{% else %}<span class="tag">{{ l.type }}</span>{% endif %}
</td>
<td>{{ l.subject }}
{% if l.detail %}<div class="muted small">{{ l.detail }}</div>{% endif %}
</td>
<td>{{ l.count }}</td>
<td>{% if l.status=='ok' %}<span class="tag ok">成功</span>{% else %}<span class="tag err">失败</span>{% endif %}</td>
<td>{{ l.sent_at }}</td>
<td class="small">{{ l.sent_at }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">暂无通知记录</td></tr>
<tr><td colspan="6" class="empty">暂无匹配的通知记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pager">
{% if page > 1 %}<a class="btn" href="/logs?page={{ page-1 }}&type={{ f_type }}&status={{ f_status }}&q={{ q }}">上一页</a>{% endif %}
<span class="muted">第 {{ page }} / {{ pages }} 页</span>
{% if page < pages %}<a class="btn" href="/logs?page={{ page+1 }}&type={{ f_type }}&status={{ f_status }}&q={{ q }}">下一页</a>{% endif %}
</div>
{% endblock %}
{% block script %}
<script>
@@ -32,5 +68,9 @@ async function testMail(){
const r = await API.json('/api/actions', {action:'test_mail'});
toast(r.ok ? '✅ 测试邮件已发送' : ('❌ ' + r.error), r.ok);
}
async function testErrorMail(){
const r = await API.json('/api/actions', {action:'test_error_mail'});
toast(r.ok ? '✅ 测试错误通知邮件已发送' : ('❌ ' + r.error), r.ok);
}
</script>
{% endblock %}
+96 -2
View File
@@ -98,7 +98,72 @@
</section>
<section class="card">
<h3>🤖 大模型接口
<h3>🌐 网页提取服务(web-capture-api
<span class="muted small">(数据源真实网页默认通过该服务抓取,每源可单独选获取方式与参数)</span>
</h3>
<div class="setting-row">
<label>启用 web-capture-api 抓取</label>
<input type="checkbox" id="w_enabled" {{ 'checked' if auto.webcapture.enabled }}>
</div>
<div class="setting-row">
<label>API 地址</label>
<input id="w_api_url" value="{{ auto.webcapture.api_url }}" placeholder="http://121.40.164.32:16025">
</div>
<div class="setting-row">
<label>抓取超时(秒)</label>
<input type="number" id="w_timeout" value="{{ auto.webcapture.timeout }}">
</div>
<div class="setting-row">
<label>连通性测试</label>
<span style="display:flex;gap:8px;align-items:center;">
<button class="btn mini" type="button" onclick="testWebcapture()">🧪 测试接口</button>
<span id="wc_test_result" class="muted small"></span>
</span>
</div>
</section>
<section class="card">
<h3>⚠️ 系统错误邮件通知
<span class="muted small">(采集/分析/通知/汇总/系统异常自动邮件通知,可设通知频率与静默时段)</span>
</h3>
<div class="setting-row">
<label>启用错误邮件通知</label>
<input type="checkbox" id="e_enabled" {{ 'checked' if auto.errnotify.enabled }}>
</div>
<div class="setting-row">
<label>通知方式</label>
<select id="e_mode">
<option value="immediate" {{ 'selected' if auto.errnotify.mode=='immediate' }}>⚡ 立即(出现即尝试发,仍受冷却限制)</option>
<option value="cooldown" {{ 'selected' if auto.errnotify.mode=='cooldown' }}>⏱ 按冷却聚合(到冷却时间集中发一封)</option>
<option value="off" {{ 'selected' if auto.errnotify.mode=='off' }}>🚫 关闭(仅记录不发送)</option>
</select>
</div>
<div class="setting-row">
<label>通知频率(冷却分钟)<span class="muted small">— 两次错误邮件最小间隔</span></label>
<input type="number" id="e_cooldown_min" value="{{ auto.errnotify.cooldown_min }}">
</div>
<div class="setting-row">
<label>单封最多错误条数</label>
<input type="number" id="e_max_items" value="{{ auto.errnotify.max_items }}">
</div>
<div class="setting-row">
<label>启用静默时段</label>
<input type="checkbox" id="e_quiet_enabled" {{ 'checked' if auto.errnotify.quiet_enabled }}>
</div>
<div style="margin-top:10px;">
<label style="font-size:12px;color:#6b7280;">静默时段(每行一段 <b>HH:MM-HH:MM</b>,支持跨午夜、多段;静默期不发送,结束后自动补发)</label>
<textarea id="e_quiet_periods" rows="3" style="margin-top:4px;">{{ '\n'.join(auto.errnotify.quiet_periods) }}</textarea>
</div>
<div class="setting-row">
<label>测试</label>
<span style="display:flex;gap:8px;align-items:center;">
<button class="btn mini" type="button" onclick="testErrorMail()">📨 发一封测试错误通知</button>
<span id="e_test_result" class="muted small"></span>
</span>
</div>
</section>
<section class="card">
<span class="muted small">(切换后即时生效,智能分析将使用当前接口;失败自动切换下一个可用接口)</span>
</h3>
<div class="form-row" style="margin-bottom:12px;">
@@ -170,10 +235,39 @@ async function saveAll(){
custom_summary_window_hours: parseInt(G('c_custom_summary_window_hours'))||24,
custom_max_summary_items: parseInt(G('c_custom_max_summary_items'))||20,
};
const r = await API.json('/api/settings', { ...auto, mail, custom });
const webcapture = {
enabled: document.getElementById('w_enabled').checked ? 1 : 0,
api_url: G('w_api_url').trim() || 'http://121.40.164.32:16025',
timeout: parseInt(G('w_timeout')) || 60,
};
const errnotify = {
enabled: document.getElementById('e_enabled').checked ? 1 : 0,
mode: G('e_mode'),
cooldown_min: parseInt(G('e_cooldown_min')) || 60,
max_items: parseInt(G('e_max_items')) || 10,
quiet_enabled: document.getElementById('e_quiet_enabled').checked ? 1 : 0,
quiet_periods: G('e_quiet_periods').split(/[\n,;,;]/).map(s=>s.trim()).filter(Boolean),
};
const r = await API.json('/api/settings', { ...auto, mail, custom, webcapture, errnotify });
toast(r.ok ? '✅ 设置已保存' : '❌ 保存失败', r.ok);
}
async function testWebcapture(){
const el = document.getElementById('wc_test_result');
el.textContent = '⏳ 测试中...';
const r = await API.json('/api/actions', {action:'test_webcapture'});
if (r.ok) el.textContent = `${r.api_url} 正常,抓取到:${r.title}${(r.text||'').trim()}`;
else el.textContent = '❌ ' + (r.error || '测试失败');
}
async function testErrorMail(){
const el = document.getElementById('e_test_result');
el.textContent = '⏳ 发送中...';
const r = await API.json('/api/actions', {action:'test_error_mail'});
if (r.ok) el.textContent = '✅ 测试错误通知邮件已发送';
else el.textContent = '❌ ' + (r.error || '发送失败');
}
async function addProvider(){
const r = await API.json('/api/llm', {
action:'add',
+78 -5
View File
@@ -9,13 +9,14 @@
<div class="card">
<table>
<thead><tr><th>ID</th><th>名称</th><th>监控方式</th><th>类型</th><th>权重</th><th>采集周期</th><th>状态</th><th>最近采样</th><th>条数</th><th>操作</th></tr></thead>
<thead><tr><th>ID</th><th>名称</th><th>监控方式</th><th>获取方式</th><th>类型</th><th>权重</th><th>采集周期</th><th>状态</th><th>最近采样</th><th>条数</th><th>操作</th></tr></thead>
<tbody>
{% for s in sources %}
<tr data-sid="{{ s.id }}" data-name="{{ s.name }}" data-type="{{ s.type }}" data-url="{{ s.url }}" data-desc="{{ s.description }}" data-weight="{{ s.weight }}" data-kind="{{ s.kind }}" data-standard="{{ s.monitor_standard or '' }}" data-interval="{{ s.scan_interval_min or 0 }}">
<tr data-sid="{{ s.id }}" data-name="{{ s.name }}" data-type="{{ s.type }}" data-url="{{ s.url }}" data-desc="{{ s.description }}" data-weight="{{ s.weight }}" data-kind="{{ s.kind }}" data-standard="{{ s.monitor_standard or '' }}" data-interval="{{ s.scan_interval_min or 0 }}" data-fetch="{{ s.fetch_method or 'auto' }}" data-capture="{{ s.capture_params or '{}' }}">
<td>{{ s.id }}</td>
<td><b>{{ s.name }}</b><br><span class="muted small">{{ s.description }}</span><br><span class="muted small" style="word-break:break-all;">{{ s.url }}</span></td>
<td>{% if s.kind == 'custom' %}<span class="tag hot">🎯 定制监控</span>{% else %}<span class="tag ok">普通</span>{% endif %}</td>
<td>{% if s.fetch_method == 'direct' %}<span class="tag">⚡ 直接抓取</span>{% elif s.fetch_method == 'webcapture' %}<span class="tag">🌐 web-capture</span>{% else %}<span class="tag ok">🌐 自动</span>{% endif %}</td>
<td>{{ s.type }}</td>
<td>{% if s.kind == 'custom' %}<span class="muted small"></span>{% else %}{{ s.weight }}{% endif %}</td>
<td>{% if s.scan_interval_min %}{{ s.scan_interval_min }}分{% else %}<span class="muted small">默认</span>{% endif %}</td>
@@ -33,8 +34,9 @@
</tbody>
</table>
<div class="muted small" style="margin-top:8px;">
💡 真实 URL 源自动抓取页面并清洗可读正文入库example.com 等占位地址走模拟数据。<br>
💡 真实 URL 源默认通过 web-capture-api 网页提取服务抓取(可每源改获取方式与参数)example.com 等占位地址走模拟数据。<br>
⏱ 采集周期:0=跟随所属机制全局值(普通源=设置页采集间隔,定制源=定制监控间隔),填分钟数则本源自定义。<br>
🌐 获取方式:<b>自动</b>=优先 web-capture-api、失败回退直接抓取;<b>web-capture</b>=仅走网页提取服务;<b>直接抓取</b>=requests+正文清洗。设置页可改 web-capture-api 地址。<br>
🎯 <b>定制监控</b>:与新闻监控独立,无权重,按「推送标准」由大模型判断是否推送,命中实时推送 + 单独汇总。
</div>
</div>
@@ -59,6 +61,35 @@
<label>采集周期(分钟)<span class="muted small">— 0 表示跟随所属机制的全局间隔</span></label>
<input id="m_interval" type="number" min="0" step="1" value="0" placeholder="0=跟随全局(普通源30 / 定制源15)">
<label>获取网页方式<span class="muted small">— 真实 URL 网页通过 web-capture-api 抓取(设置页可改接口地址)</span></label>
<select id="m_fetch" onchange="onFetchChange()">
<option value="auto">🌐 自动(优先 web-capture-api,失败回退直接抓取)</option>
<option value="webcapture">🌐 web-capture-api(网页提取服务,反爬强)</option>
<option value="direct">⚡ 直接抓取(requests + 正文清洗)</option>
</select>
<div id="m_capture_row">
<label>抓取动作<span class="muted small">— html 提取资讯链接;text 整页作为一条资讯</span></label>
<select id="m_c_action">
<option value="html">html(提取链接)</option>
<option value="text">text(直接取正文)</option>
</select>
<div class="form-row" style="justify-content:flex-start;margin-top:6px;">
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;white-space:nowrap;">等待加载(ms)
<input id="m_c_wait" type="number" value="2000" style="width:90px;"></label>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;white-space:nowrap;">滚动次数
<input id="m_c_scroll" type="number" value="0" style="width:70px;"></label>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;white-space:nowrap;">后端
<select id="m_c_backend" style="width:130px;">
<option value="auto">auto</option>
<option value="agent-browser">agent-browser</option>
<option value="playwright">playwright</option>
</select></label>
</div>
<label>高级参数(JSON,可留空)</label>
<textarea id="m_c_extra" rows="2" placeholder='{"scroll_delay":1000,"full_page":false,"viewport":{"width":1280,"height":800}}'></textarea>
</div>
<div id="m_weight_row">
<label>权重(综合评分中该源占比,0.1~2.0)</label>
<input id="m_weight" type="number" step="0.1" min="0.1" max="2" value="0.8">
@@ -130,13 +161,28 @@ function openModal(id){
setV('m_kind', row.dataset.kind || 'normal');
setV('m_standard', row.dataset.standard || '');
setV('m_interval', row.dataset.interval || '0');
const cap = parseCapture(row.dataset.capture);
setV('m_fetch', row.dataset.fetch || 'auto');
setV('m_c_action', cap.action || 'html');
setV('m_c_wait', cap.wait_time != null ? cap.wait_time : '2000');
setV('m_c_scroll', cap.scroll_times != null ? cap.scroll_times : '0');
setV('m_c_backend', cap.backend || 'auto');
const extra = {...cap};
['action','wait_time','scroll_times','backend'].forEach(k => delete extra[k]);
setV('m_c_extra', Object.keys(extra).length ? JSON.stringify(extra) : '');
} else {
['m_name','m_type','m_url','m_desc','m_standard'].forEach(k=>setV(k,''));
['m_name','m_type','m_url','m_desc','m_standard','m_c_extra'].forEach(k=>setV(k,''));
setV('m_weight', '0.8');
setV('m_kind', 'normal');
setV('m_interval', '0');
setV('m_fetch', 'auto');
setV('m_c_action', 'html');
setV('m_c_wait', '2000');
setV('m_c_scroll', '0');
setV('m_c_backend', 'auto');
}
onKindChange();
onFetchChange();
document.getElementById('sourceModal').style.display = 'flex';
}
function closeModal(){ document.getElementById('sourceModal').style.display = 'none'; editId = null; }
@@ -147,14 +193,41 @@ function onKindChange(){
document.getElementById('m_standard_row').style.display = custom ? 'block' : 'none';
}
function onFetchChange(){
document.getElementById('m_capture_row').style.display =
getV('m_fetch') === 'direct' ? 'none' : 'block';
}
function parseCapture(s){
try { const o = JSON.parse(s || '{}'); return o && typeof o === 'object' ? o : {}; } catch(e){ return {}; }
}
async function saveSource(){
const kind = getV('m_kind');
let captureParams = '{}';
if (getV('m_fetch') !== 'direct'){
const base = {
action: getV('m_c_action') || 'html',
wait_time: parseInt(getV('m_c_wait')) || 2000,
scroll_times: parseInt(getV('m_c_scroll')) || 0,
backend: getV('m_c_backend') || 'auto'
};
let adv = {};
const extraTxt = getV('m_c_extra').trim();
if (extraTxt){
try { adv = JSON.parse(extraTxt); }
catch(e){ toast('高级参数 JSON 格式错误', false); return; }
}
captureParams = JSON.stringify({ ...base, ...adv });
}
const body = {
name: getV('m_name').trim(), type: getV('m_type').trim(), url: getV('m_url').trim(),
description: getV('m_desc').trim(), kind,
monitor_standard: kind === 'custom' ? getV('m_standard').trim() : '',
weight: parseFloat(getV('m_weight') || '1') || 1,
scan_interval_min: parseInt(getV('m_interval')) || 0
scan_interval_min: parseInt(getV('m_interval')) || 0,
fetch_method: getV('m_fetch'),
capture_params: captureParams
};
if (!body.name){ toast('请填写名称', false); return; }
if (kind === 'custom' && !body.monitor_standard){ toast('请填写推送标准', false); return; }