Files

383 lines
19 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% set active = 'sources' %}
{% block title %}数据源 - 新闻智能跟踪{% endblock %}
{% block content %}
<div class="page-head">
<h1>📡 数据源管理</h1>
<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 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-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>
<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>
<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="toggleFromButton({{ s.id }})">{{ '停用' if s.enabled else '启用' }}</button>
<button class="btn mini danger" onclick="delSource({{ s.id }})">删除</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="muted small" style="margin-top:8px;">
💡 真实 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>
<!-- 新增 / 编辑 数据源 模态框 -->
<div class="modal-mask" id="sourceModal" style="display:none;">
<div class="modal">
<div class="modal-head">
<h3 id="modalTitle">新增数据源</h3>
<span class="modal-x" onclick="closeModal()"></span>
</div>
<div class="modal-body">
<label>名称 *</label>
<input id="m_name" placeholder="如:36氪 AI频道">
<label>监控方式</label>
<select id="m_kind" onchange="onKindChange()">
<option value="normal">普通数据源(新闻监控机制,按权重打分推送)</option>
<option value="custom">定制监控(定制监控机制,无权重,按推送标准由大模型判断推送)</option>
</select>
<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">
</div>
<div id="m_standard_row" style="display:none;">
<label>推送标准 *(大模型据此判断每条资讯是否达到推送条件)</label>
<textarea id="m_standard" rows="3" placeholder="例如:仅推送英伟达重大事件——股价单日涨跌超10%,或重大产品发布/回购/分拆/收购等实质性公告,或分析师大幅上调目标价;普通行情波动与日常新闻一律不推送。"></textarea>
<div class="form-row" style="justify-content:flex-start;">
<button class="btn mini" type="button" onclick="testStandard()">🧪 测试推送标准</button>
<span id="m_test_result" class="muted small"></span>
</div>
</div>
<label>类型(分类标签)</label>
<input id="m_type" placeholder="科技媒体/公司动态/学术/政策...">
<label>URL / RSS 地址</label>
<input id="m_url" placeholder="example.com 开头走模拟数据">
<label>描述</label>
<textarea id="m_desc" rows="2" placeholder="该源提供哪些内容"></textarea>
</div>
<div class="modal-foot">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn accent" onclick="saveSource()">保存</button>
</div>
</div>
</div>
<!-- 历史采样 模态框 -->
<div class="modal-mask" id="histModal" style="display:none;">
<div class="modal" style="width:640px;">
<div class="modal-head">
<h3 id="histTitle">历史采样</h3>
<span class="modal-x" onclick="document.getElementById('histModal').style.display='none'"></span>
</div>
<div class="modal-body">
<div class="form-row" style="margin-bottom:8px;">
<button class="btn mini" onclick="openHistArticles()">📰 查看本源采集资讯</button>
<span id="hist_meta" class="muted small"></span>
</div>
<div id="hist_snaps"></div>
<div id="hist_articles" style="display:none;margin-top:8px;border-top:1px solid #f3f4f6;padding-top:8px;"></div>
</div>
<div class="modal-foot">
<button class="btn" onclick="document.getElementById('histModal').style.display='none'">关闭</button>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
let editId = null;
let histSourceId = null;
function openModal(id){
editId = id || null;
document.getElementById('modalTitle').textContent = editId ? '编辑数据源' : '新增数据源';
document.getElementById('m_test_result').textContent = '';
if (editId){
const row = document.querySelector(`tr[data-sid="${editId}"]`);
if (!row) return;
setV('m_name', row.dataset.name);
setV('m_type', row.dataset.type);
setV('m_url', row.dataset.url);
setV('m_desc', row.dataset.desc);
setV('m_weight', row.dataset.weight || '0.8');
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','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; }
function onKindChange(){
const custom = getV('m_kind') === 'custom';
document.getElementById('m_weight_row').style.display = custom ? 'none' : 'block';
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,
fetch_method: getV('m_fetch'),
capture_params: captureParams
};
if (!body.name){ toast('请填写名称', false); return; }
if (kind === 'custom' && !body.monitor_standard){ toast('请填写推送标准', false); return; }
const r = await API.json('/api/sources',
editId ? {...body, action:'update', id: editId} : {...body, action:'add'});
toast(r.ok ? '✅ 已保存' : '❌ 保存失败', r.ok);
if (r.ok){ closeModal(); setTimeout(()=>location.reload(), 400); }
}
async function testStandard(){
const standard = getV('m_standard').trim();
if (!standard){ toast('请先填写推送标准', false); return; }
const el = document.getElementById('m_test_result');
el.textContent = '⏳ 大模型分析中...';
const r = await API.json('/api/sources', {action:'test_standard', monitor_standard: standard});
if (r.ok){
el.textContent = r.meets ? '✅ 命中标准,会推送 · ' + (r.reason||'') : '❌ 未达标准,不推送 · ' + (r.reason||'');
} else {
el.textContent = '❌ ' + (r.error || '测试失败');
}
}
// ---------- 历史采样 ----------
async function openHistory(id){
histSourceId = id;
const row = document.querySelector(`tr[data-sid="${id}"]`);
document.getElementById('histTitle').textContent = `📜 历史采样 · ${row ? row.dataset.name : ''}`;
document.getElementById('hist_articles').style.display = 'none';
document.getElementById('hist_articles').innerHTML = '';
document.getElementById('histModal').style.display = 'flex';
const r = await API.get('/api/sources/history', {source_id: id, limit: 100});
document.getElementById('hist_meta').textContent = r.ok ? `共 ${r.total} 次采样` : '加载失败';
if (!r.ok){ document.getElementById('hist_snaps').innerHTML = '<div class="empty">加载失败</div>'; return; }
const snaps = r.snapshots;
if (!snaps.length){
document.getElementById('hist_snaps').innerHTML = '<div class="empty">暂无采样记录(保存后由后台采集生成)</div>';
return;
}
document.getElementById('hist_snaps').innerHTML =
'<table><thead><tr><th>#</th><th>采样时间</th><th>采集条数</th><th>状态</th><th>备注</th></tr></thead><tbody>' +
snaps.map(s => `<tr>
<td>${s.id}</td><td>${s.fetched_at}</td><td>${s.count}</td>
<td>${s.status==='ok' ? '<span class="tag ok">成功</span>' : '<span class="tag err">失败</span>'}</td>
<td class="small muted">${escapeHtml(s.detail||'')}</td>
</tr>`).join('') + '</tbody></table>';
}
async function openHistArticles(){
if (!histSourceId) return;
const el = document.getElementById('hist_articles');
el.style.display = 'block';
el.innerHTML = '<div class="empty">⏳ 加载资讯列表...</div>';
const r = await API.get('/api/sources/articles', {source_id: histSourceId, page_size: 30});
if (!r.ok){ el.innerHTML = '<div class="empty">加载失败</div>'; return; }
const arts = r.articles;
if (!arts.length){ el.innerHTML = `<div class="empty">本源暂无采集到的资讯(共 ${r.total} 条)</div>`; return; }
el.innerHTML = `<div class="muted small" style="margin-bottom:6px;">本源共采集 ${r.total} 条资讯(最新 ${arts.length} 条):</div>` +
'<div class="hist-list">' + arts.map(a =>
`<div class="hist-item"><a href="/news/${a.id}" target="_blank"><b>${escapeHtml(a.title)}</b></a>
<div class="muted small">${a.collected_at} · 综合分${a.total_score}${a.is_important ? ' · <span style="color:#dc2626;">重要</span>' : ''}</div></div>`
).join('') + '</div>';
}
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);
}
function getV(id){ return document.getElementById(id).value; }
function setV(id, v){ document.getElementById(id).value = v; }
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
</script>
{% endblock %}