Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6abe7ad6 | ||
|
|
c9671880e8 |
@@ -2,7 +2,7 @@
|
||||
|
||||
一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。
|
||||
|
||||
> 当前版本 **v1.3.0**:数据源**独立采集周期**(每源可覆盖全局)+ **定制监控与新闻监控完全分离**(独立间隔/独立汇总)+ **历史采样留档与提取**。
|
||||
> 当前版本 **v1.4.2**:修复日报/定制汇总重复发送刷屏 + 数据源一键全部启用/停用 + 数据源快速筛选。
|
||||
|
||||
---
|
||||
|
||||
@@ -236,4 +236,4 @@ cd works/news-tracker
|
||||
## Git
|
||||
|
||||
- 仓库:`hz4th_coder/news-tracker`
|
||||
- 版本:`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 抓取+获取方式与参数可编辑 / 系统错误邮件通知:频率+静默时段)
|
||||
- 版本:`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 抓取+获取方式与参数可编辑 / 系统错误邮件通知:频率+静默时段)→ **`v1.4.1`**(修复邮件退信:发信补 `Date` 头,mail.tphai.com amavisd 强制要求否则 554 退信 / 数据源状态列新增一键启动/停用开关,点击即生效无需刷新)→ **`v1.4.2`**(**修复日报/定制汇总刷屏**:`_maybe_daily` 日期判断元组嵌套 bug 导致每天只发一次的逻辑失效、到点后每 30s 重复发;改为纯字符串 + 发送日期持久化到 settings,重启也不重复 / **一键全部启用/停用**:数据源页顶部两个按钮 + `/api/sources` 新 action enable_all/disable_all / **快速筛选**:关键词搜索 + 监控方式 + 启用状态 三条件过滤,重置一键还原)
|
||||
@@ -189,6 +189,12 @@ def api_sources():
|
||||
s = db.get_source(data["id"])
|
||||
db.update_source(data["id"], enabled=0 if s["enabled"] else 1)
|
||||
return jsonify({"ok": True})
|
||||
if action == "enable_all":
|
||||
n = db.set_all_sources_enabled(1)
|
||||
return jsonify({"ok": True, "count": n})
|
||||
if action == "disable_all":
|
||||
n = db.set_all_sources_enabled(0)
|
||||
return jsonify({"ok": True, "count": n})
|
||||
if action == "test_standard":
|
||||
# 定制监控:用大模型测试「推送标准」是否好使(给一段示例内容看判不判得出)
|
||||
standard = (data.get("monitor_standard") or "").strip()
|
||||
|
||||
@@ -290,6 +290,16 @@ def delete_source(sid):
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_all_sources_enabled(val):
|
||||
"""一键全部启用/停用。返回受影响的数据源数。"""
|
||||
conn = get_conn()
|
||||
cur = conn.execute("UPDATE sources SET enabled=?", (1 if val else 0,))
|
||||
conn.commit()
|
||||
n = cur.rowcount
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
def update_source_fetch(sid, status="ok", count=0):
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
|
||||
+3
-1
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from email.header import Header
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
from email.utils import formataddr, formatdate
|
||||
|
||||
import config
|
||||
import db
|
||||
@@ -27,6 +27,8 @@ def send_email(subject, html, to=None):
|
||||
msg["From"] = formataddr((str(Header(cfg.get("sender_name", "新闻智能跟踪"), "utf-8")), cfg["smtp_user"]))
|
||||
msg["To"] = to
|
||||
msg["Subject"] = Header(subject, "utf-8")
|
||||
# mail.tphai.com 的 amavisd 强制要求 Date 头,缺失会退信(554 5.6.0 BAD HEADER)
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
|
||||
mode = cfg.get("smtp_mode", "plain")
|
||||
|
||||
+15
-9
@@ -147,11 +147,16 @@ def send_custom_summary():
|
||||
return notifier.send_custom_summary(articles, label)
|
||||
|
||||
|
||||
def _maybe_daily(now, last_day, time_key, send_fn, kind):
|
||||
"""到点触发的通用逻辑。time_key 存 'HH:MM',send_fn 返回发送条数。"""
|
||||
def _maybe_daily(now, last_day, time_key, send_fn, kind, persist_key):
|
||||
"""到点触发的通用逻辑。time_key 存 'HH:MM',send_fn 返回发送条数。
|
||||
last_day 存上次发送的日期字符串 'YYYY-MM-DD'(None=尚未发送)。
|
||||
修复:每天只发一次——last_day 必须是纯字符串,不能嵌套元组;
|
||||
并把已发送日期持久化到 settings,服务重启后也不会当天重复发。"""
|
||||
day_key = now.strftime("%Y-%m-%d")
|
||||
if last_day[0] == day_key:
|
||||
return last_day
|
||||
if last_day is None:
|
||||
last_day = db.get_setting(persist_key, "")
|
||||
if last_day == day_key:
|
||||
return last_day # 今天已发过,不再发
|
||||
hm = str(db.get_setting(time_key, config.AUTO_DEFAULTS.get(time_key) or
|
||||
config.CUSTOM_DEFAULTS.get(time_key) or "10:00"))
|
||||
try:
|
||||
@@ -161,7 +166,8 @@ def _maybe_daily(now, last_day, time_key, send_fn, kind):
|
||||
if now.strftime("%H:%M") >= hm and now.hour >= hh:
|
||||
try:
|
||||
send_fn()
|
||||
last_day = (day_key,)
|
||||
db.set_setting(persist_key, day_key)
|
||||
return 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])
|
||||
@@ -174,10 +180,10 @@ def scheduler_loop(stop_event):
|
||||
while not stop_event.is_set():
|
||||
now = datetime.now()
|
||||
try:
|
||||
last_summary_day = _maybe_daily(now, (last_summary_day,), "summary_time",
|
||||
send_daily_summary, "summary")
|
||||
last_custom_summary_day = _maybe_daily(now, (last_custom_summary_day,), "custom_summary_time",
|
||||
send_custom_summary, "custom_summary")
|
||||
last_summary_day = _maybe_daily(now, last_summary_day, "summary_time",
|
||||
send_daily_summary, "summary", "summary_last_day")
|
||||
last_custom_summary_day = _maybe_daily(now, last_custom_summary_day, "custom_summary_time",
|
||||
send_custom_summary, "custom_summary", "custom_summary_last_day")
|
||||
except Exception as e:
|
||||
notifier.report_error("汇总", "定时汇总检查异常", str(e)[:300])
|
||||
# 采集到期数据源(每源独立周期)
|
||||
|
||||
@@ -68,6 +68,14 @@ a { color:#2563eb; text-decoration:none; }
|
||||
.tag.ok { background:#ecfdf5; color:#059669; }
|
||||
.tag.err { background:#fef2f2; color:#dc2626; }
|
||||
|
||||
/* 一键启停开关 */
|
||||
.switch { position:relative; display:inline-block; width:38px; height:20px; vertical-align:middle; cursor:pointer; }
|
||||
.switch input { opacity:0; width:0; height:0; }
|
||||
.switch .slider { position:absolute; inset:0; background:#d1d5db; border-radius:20px; transition:.2s; }
|
||||
.switch .slider:before { content:""; position:absolute; height:14px; width:14px; left:3px; top:3px; background:#fff; border-radius:50%; transition:.2s; }
|
||||
.switch input:checked + .slider { background:#059669; }
|
||||
.switch input:checked + .slider:before { transform:translateX(18px); }
|
||||
|
||||
.filters { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
|
||||
.filters select, .filters input { padding:8px 10px; border:1px solid #d1d5db; border-radius:8px; background:#fff; font-size:13px; }
|
||||
.filters input[type=text]{ width:260px; }
|
||||
|
||||
+85
-6
@@ -4,15 +4,37 @@
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>📡 数据源管理</h1>
|
||||
<button class="btn accent" onclick="openModal()">+ 新增数据源</button>
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="setAllSources(1)">✅ 全部启用</button>
|
||||
<button class="btn" onclick="setAllSources(0)">⏸ 全部停用</button>
|
||||
<button class="btn accent" onclick="openModal()">+ 新增数据源</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="form-row" style="justify-content:flex-start;gap:10px;flex-wrap:wrap;">
|
||||
<input id="f_keyword" placeholder="🔍 搜索名称 / 类型 / URL / 描述..."
|
||||
style="flex:1;min-width:220px;" oninput="applyFilter()">
|
||||
<select id="f_kind" onchange="applyFilter()" style="width:150px;">
|
||||
<option value="">全部监控方式</option>
|
||||
<option value="normal">普通数据源</option>
|
||||
<option value="custom">🎯 定制监控</option>
|
||||
</select>
|
||||
<select id="f_enabled" onchange="applyFilter()" style="width:120px;">
|
||||
<option value="">全部状态</option>
|
||||
<option value="1">启用</option>
|
||||
<option value="0">停用</option>
|
||||
</select>
|
||||
<button class="btn mini" onclick="clearFilter()">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<table id="srcTable">
|
||||
<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 }}" data-fetch="{{ s.fetch_method or 'auto' }}" data-capture="{{ s.capture_params or '{}' }}">
|
||||
<tr data-sid="{{ s.id }}" data-enabled="{{ s.enabled }}" 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>
|
||||
@@ -20,13 +42,19 @@
|
||||
<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>
|
||||
<td>{% if s.enabled %}<span class="tag ok">启用</span>{% else %}<span class="tag">停用</span>{% endif %}</td>
|
||||
<td>
|
||||
<label class="switch" title="一键启动/停用">
|
||||
<input type="checkbox" onchange="toggleSource({{ s.id }}, this)" {{ 'checked' if s.enabled }}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span id="st-{{ s.id }}" class="tag {{ 'ok' if s.enabled }}">{{ '启用' if s.enabled else '停用' }}</span>
|
||||
</td>
|
||||
<td>{{ s.last_fetch or '—' }}</td>
|
||||
<td>{{ s.last_count }}</td>
|
||||
<td>
|
||||
<button class="btn mini" onclick="openModal({{ s.id }})">✏️ 编辑</button>
|
||||
<button class="btn mini" onclick="openHistory({{ s.id }})">📜 历史采样</button>
|
||||
<button class="btn mini" onclick="toggleSource({{ s.id }})">{{ '停用' if s.enabled else '启用' }}</button>
|
||||
<button class="btn mini" onclick="toggleFromButton({{ s.id }})">{{ '停用' if s.enabled else '启用' }}</button>
|
||||
<button class="btn mini danger" onclick="delSource({{ s.id }})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -291,7 +319,58 @@ async function openHistArticles(){
|
||||
).join('') + '</div>';
|
||||
}
|
||||
|
||||
async function toggleSource(id){ await API.json('/api/sources', {action:'toggle', id}); location.reload(); }
|
||||
async function toggleSource(id, cb){
|
||||
cb = cb || document.querySelector(`tr[data-sid="${id}"] .switch input`);
|
||||
if (!cb) return;
|
||||
const orig = cb.checked;
|
||||
const r = await API.json('/api/sources', {action:'toggle', id});
|
||||
if (!r.ok){ toast('操作失败', false); cb.checked = orig; return; }
|
||||
const on = cb.checked;
|
||||
const row = document.querySelector(`tr[data-sid="${id}"]`);
|
||||
if (row) row.setAttribute('data-enabled', on ? '1' : '0');
|
||||
const tag = document.getElementById('st-'+id);
|
||||
if (tag){ tag.textContent = on ? '启用' : '停用'; tag.className = 'tag ' + (on ? 'ok' : ''); }
|
||||
const nameEl = document.querySelector(`tr[data-sid="${id}"] b`);
|
||||
const name = nameEl ? nameEl.textContent : '';
|
||||
toast((on ? '✅ 已启用「' : '⏸ 已停用「') + name + '」', true);
|
||||
applyFilter();
|
||||
}
|
||||
function toggleFromButton(id){
|
||||
const cb = document.querySelector(`tr[data-sid="${id}"] .switch input`);
|
||||
if (!cb) return;
|
||||
cb.checked = !cb.checked;
|
||||
toggleSource(id, cb);
|
||||
}
|
||||
|
||||
// ---------- 快速筛选 ----------
|
||||
function applyFilter(){
|
||||
const kw = (document.getElementById('f_keyword').value || '').trim().toLowerCase();
|
||||
const kind = document.getElementById('f_kind').value;
|
||||
const en = document.getElementById('f_enabled').value;
|
||||
document.querySelectorAll('#srcTable tbody tr').forEach(tr => {
|
||||
const text = ((tr.dataset.name||'') + ' ' + (tr.dataset.type||'') + ' ' + (tr.dataset.url||'') + ' ' + (tr.dataset.desc||'')).toLowerCase();
|
||||
const okKw = !kw || text.includes(kw);
|
||||
const okKind = !kind || (tr.dataset.kind||'normal') === kind;
|
||||
const okEn = en === '' || (tr.dataset.enabled||'1') === en;
|
||||
tr.style.display = (okKw && okKind && okEn) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function clearFilter(){
|
||||
document.getElementById('f_keyword').value = '';
|
||||
document.getElementById('f_kind').value = '';
|
||||
document.getElementById('f_enabled').value = '';
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
// ---------- 一键全部启用 / 全部停用 ----------
|
||||
async function setAllSources(val){
|
||||
const action = val ? 'enable_all' : 'disable_all';
|
||||
if (!confirm(val ? '确定要【全部启用】所有数据源吗?' : '确定要【全部停用】所有数据源吗?')) return;
|
||||
const r = await API.json('/api/sources', {action});
|
||||
if (!r.ok){ toast('操作失败', false); return; }
|
||||
toast((val ? '✅ 已全部启用 ' : '⏸ 已全部停用 ') + (r.count||0) + ' 个数据源', true);
|
||||
setTimeout(()=>location.reload(), 400);
|
||||
}
|
||||
async function delSource(id){
|
||||
if (!confirm('确认删除该数据源?')) return;
|
||||
await API.json('/api/sources', {action:'delete', id}); toast('✅ 已删除'); setTimeout(()=>location.reload(), 400);
|
||||
|
||||
Reference in New Issue
Block a user