7 Commits
Author SHA1 Message Date
hz4th_coder 8c0c042649 v1.8.0 智能截图多视觉大模型接口配置(优先级+失败降级) + 提取历史图示统计
1) 智能截图·视觉大模型配置:
   - 支持配置多个视觉大模型接口(均含视觉能力), 按列表顺序=优先级调用
   - 失败/报错/超时自动降级到下一个接口, 全部失败才保守停止滚动
   - 预置两个高优先级接口: 本地18008 Qwen3.8-27B-NVFP4 + SiliconFlow Kimi-K2.6
   - 前端弹窗: 接口增删改/启用开关/⬆⬇调整优先级/逐接口🧪测试连接
   - /api/smart/test 支持 endpoint_id 测试已保存接口; 旧单接口配置自动迁移为 endpoints[0]
   - 每步判断记录所用模型名+接口名(详情可见降级链路)
2) 提取历史图示统计:
   - /api/history/stats?dimension=day|action|backend|status|caller|method&days=N
   - 前端📊统计弹窗: 6个角度切换 + 7/30/90天范围 + 饼图/柱状图切换 + 汇总(总数/成功率) + 数据表 + 下载PNG
   - 按天维度缺0补0, caller Top8+其他
2026-09-11 12:48:25 +08:00
hz4th_coder 17431e86cf v1.7.4 auto 默认顺序改为优先 playwright、其次 agent-browser
- 按大爷要求调整: auto 优先 playwright(Python, domcontentloaded更宽容), 其次 agent-browser(Rust)
- capture_webpage + smart_capture 的 auto 选路同步调整
- 新增对称兜底: playwright 失败自动切 agent-browser 重试(原 agent-browser超时→playwright 逻辑保留)
- /api/backends 与前端选择器说明同步更新(auto label/priority)
2026-08-31 17:23:01 +08:00
hz4th_coder 82ff8a2610 v1.7.3 前端新增后端选择器 + 暴露 /api/backends + 接通 chrome-cdp 后端
- 新增 GET /api/backends 返回可用后端/默认顺序/说明,供前端动态渲染
- 前端表单新增「捕获后端」下拉框: 自动(默认,优先agent-browser超时自动回退playwright)/agent-browser/playwright/chrome-cdp,不可用的置灰
- 接通 chrome-cdp 后端: 之前函数已实现但 capture_webpage 未接线(传 backend=chrome-cdp 会报 Unknown backend),现支持 screenshot/html/text,连接已开的 Chrome(--remote-debugging-port)
- 前端 currentData 携带 backend 字段
2026-08-31 16:56:22 +08:00
hz4th_coder 1e69e7ca57 v1.7.2 修复慢加载页面报 'Operation timed out':agent-browser 打开超时自动切 Playwright 重试
- 根因: agent-browser open 导航内部超时约30s(CDP command timed out: Page.navigate),慢加载/持续加载页面(如资源挂起导致 load 事件永不触发)会报 Failed to open URL: Operation timed out
- agent-browser open 子进程超时 30s→45s,让其返回自己的超时报错而非被硬杀
- 超时判定放宽到 timed out/timeout 全变体,触发自动回退: capture_webpage 与 smart_capture 在 agent-browser 打开超时时自动切 Playwright 重试(domcontentloaded 更宽容,不等全部资源)
- Playwright goto domcontentloaded 超时 30s→60s
- 错误提示更友好(说明慢加载+已自动回退+建议)
- 实测: 挂起资源测试页 agent-browser 超时→playwright 成功, 耗时~39s; 正常页面无回归
2026-08-31 16:51:17 +08:00
hz4th_coder 951f941eee v1.7.1 修复前端捕获报错: fetch headers 不允许非 ISO-8859-1 字符(中文'游客'导致 Failed to read headers)
- 前端 /api/capture 请求头去掉 X-Caller: '游客'(中文在 HTTP 头非法,浏览器 fetch 直接抛错)
- 保留 X-From: web(ASCII 安全),调用者缺省由服务端默认 游客,行为不变
2026-08-31 16:27:04 +08:00
hz4th_coder 9c63887e10 v1.7.0 历史记录标注调用者/调用方式 + 每次提取自动按月归档原始HTML + 修复agent-browser HTML JSON转义bug
- 历史记录新增 caller(调用者: 游客/项目名) 与 call_method(调用方式: web/api)
  * 请求体 caller 或请求头 X-Caller/X-Project/X-App 标识调用者, 默认游客
  * 请求体 call_method 或 X-From 标识方式, 缺省按 Referer 自动判定(本前端=web, 否则api)
  * 前端提取请求带 X-From:web / X-Caller:游客; news-tracker 调用带 caller=news-tracker
- 每次提取后自动保存最原始 HTML 到 data/html/<YYYY-MM>/ 按月目录归档
  * screenshot/html/text/smart 四种动作均顺带抓取原始HTML并落盘
  * 历史详情新增 📄原始HTML文件 链接(GET /api/history/<id>/html), 删除记录连带删文件
- 修复: agent-browser 后端 html 提取返回 JSON 转义文本(非最原始HTML)的bug, 现解码为干净HTML
2026-08-31 13:12:22 +08:00
hz4th_coder e5ff90c46a 新增按需截图功能:滚动截图 + 视觉大模型实时判断
- 新 action=smart:每次截图后用视觉大模型判断主题内容是否已截全、是否还需向下滚动,自动滚动到判定完成,最后纵向拼接成长图
- 默认视觉模型 qwen3.6-plus(autodl),支持 agent-browser / playwright 双后端
- LLM 接口可网页配置:⚙️智能配置弹窗(base_url/api_key/model/提示词/最大滚动次数/滚动比例/超时) + 测试连接 + 持久化到 data/config.json
- 新增 API:GET/POST /api/smart/config、POST /api/smart/test
- 安全兜底:大模型判断失败保守停止、滚动位置不再变化判底部、最大滚动次数上限
- 历史新增 smart 类型:入库拼接长图+AI判断过程日志,详情弹窗可查看每一步截图判断
- 前端:智能模式提示条 + 结果展示拼接长图 + AI判断过程折叠查看 + 历史筛选/徽章/详情
2026-08-29 18:08:22 +08:00
2 changed files with 1448 additions and 51 deletions
+837 -29
View File
File diff suppressed because it is too large Load Diff
+601 -12
View File
@@ -370,6 +370,16 @@
color: #c62828;
}
.badge-api {
background: #ede7f6;
color: #5e35b1;
}
.badge-web {
background: #e0f7fa;
color: #00695c;
}
.history-url {
max-width: 280px;
overflow: hidden;
@@ -556,7 +566,9 @@
.history-table th:nth-child(2),
.history-table td:nth-child(2),
.history-table th:nth-child(3),
.history-table td:nth-child(3) {
.history-table td:nth-child(3),
.history-table th:nth-child(4),
.history-table td:nth-child(4) {
display: none;
}
}
@@ -591,8 +603,16 @@
<input type="radio" name="action" value="text">
📝 提取文本
</label>
<label class="radio-label">
<input type="radio" name="action" value="smart">
🧠 按需截图(AI判断滚动)
</label>
<button type="button" class="btn-small" onclick="openSmartConfig()" style="margin-top:0;" title="配置视觉大模型接口">⚙️ 智能配置</button>
</div>
</div>
<div class="form-group" id="smartHint" style="display:none; padding:12px 16px; background:#eef2ff; border-radius:8px; color:#333; font-size:14px; line-height:1.8;">
🧠 <strong>按需截图模式</strong>:每次截图后由视觉大模型实时判断——是否已截全主题内容、还需不需要继续往下滚动,自动滚动直到大模型判定完成,最后拼成一张长图。支持配置<strong>多个视觉大模型接口</strong>(按优先级调用,失败自动降级到下一个),在「⚙️ 智能配置」中随时增删改。
</div>
<div class="form-group">
<label>高级选项</label>
@@ -604,6 +624,16 @@
</div>
</div>
<div class="form-group">
<label>捕获后端</label>
<select id="backend" style="max-width:100%;">
<option value="auto">加载中…</option>
</select>
<small id="backendHint" style="color: #666; display: block; margin-top: 4px;">
自动 = 优先 Playwright,失败自动回退 agent-browser(推荐)
</small>
</div>
<div class="form-group">
<label>等待设置(重要!)</label>
<div class="row">
@@ -669,10 +699,12 @@
<option value="screenshot">📸 截图</option>
<option value="html">📄 HTML</option>
<option value="text">📝 文本</option>
<option value="smart">🧠 按需截图</option>
</select>
<input type="text" class="search-input" id="historySearch" placeholder="🔍 搜索网址/标题..." onkeydown="if(event.key==='Enter')loadHistory(1)">
<button class="btn-small" onclick="loadHistory(1)">搜索</button>
<button class="btn-small" onclick="refreshHistory()">🔄 刷新</button>
<button class="btn-small" onclick="openStats()">📊 统计</button>
</div>
</div>
@@ -684,6 +716,8 @@
<tr>
<th>时间</th>
<th>类型</th>
<th>调用者</th>
<th>方式</th>
<th>网址</th>
<th>标题</th>
<th>状态</th>
@@ -703,31 +737,124 @@
<h3>📚 使用说明</h3>
<p><strong>重要提示:</strong></p>
<ul style="margin: 10px 0; line-height: 1.8;">
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图;支持<strong>多视觉大模型接口配置</strong>(「⚙️ 智能配置」中增删改,列表顺序=优先级,失败/报错自动降级到下一个接口)</li>
<li>📊 <strong>提取历史统计</strong>:历史区点「📊 统计」,可按天/操作类型/后端/状态/调用者/调用方式等角度切换图表(柱状/饼图可换,支持 7/30/90 天范围,可下载 PNG</li>
<li>📝 <strong>提取文本</strong>:自动剔除脚本/样式/标签,得到干净的前端可读文本,适合直接用于分析/喂给大模型</li>
<li>⏱️ <strong>页面加载等待</strong>:某些网站需要验证过程(如 Cloudflare),请设置较长时间(10000-30000ms</li>
<li>🔄 <strong>滚动次数</strong>:用于加载动态内容(如微博、推特等),建议 3-5 次</li>
<li>🎯 <strong>全页截图</strong>:滚动加载后建议开启此选项</li>
<li>📜 <strong>提取历史</strong>:每次提取自动入库,支持分页浏览、按类型筛选、搜索与删除</li>
<li>📜 <strong>提取历史</strong>:每次提取自动入库,支持分页浏览、按类型筛选、搜索与删除;每条记录会标注<strong>调用者</strong>(游客/项目名)与<strong>调用方式</strong>web/api),并自动把<strong>最原始 HTML</strong> 按月归档到 <code>data/html/&lt;月份&gt;/</code>(详情里可点「📄 原始HTML文件」查看)</li>
<li>👤 <strong>API 调用方标识</strong>:请求体带 <code>caller</code>(或请求头 <code>X-Caller</code>/<code>X-Project</code>)即可在历史里显示项目名;带 <code>call_method</code><code>X-From</code> 标注调用方式,缺省按 Referer 自动判定 web/api</li>
</ul>
<p style="margin-top: 15px;"><strong>API 调用:</strong></p>
<pre><code>POST /api/capture
{
"url": "https://example.com",
"action": "text", // "screenshot" | "html" | "text"
"action": "smart", // "screenshot" | "html" | "text" | "smart"
"wait_time": 15000, // 重要!验证网站需设置较长等待
"scroll_times": 3, // 可选:加载动态内容
"scroll_delay": 1000, // 可选:滚动间隔
"full_page": true, // 可选:全页截图
"backend": "playwright" // 可选:playwright / agent-browser
"backend": "auto", // 可选:auto(默认,优先playwright,失败回退agent-browser) / playwright / agent-browser / chrome-cdp
"caller": "news-tracker", // 可选:调用者标识(历史记录里显示,默认游客)
"call_method": "api", // 可选:调用方式 web/api(缺省自动判定)
"viewport": {"width":1280,"height":700},
"smart_config": { // 可选:临时覆盖按需截图配置(接口列表按顺序=优先级,失败自动降级)
"endpoints": [
{"name":"本地Qwen", "base_url":"http://121.40.164.32:18008/v1", "api_key":"xxxx", "model":"unsloth/Qwen3.8-27B-NVFP4", "enabled":true},
{"name":"Kimi", "base_url":"https://api.siliconflow.cn/v1", "api_key":"sk-xxx", "model":"Pro/moonshotai/Kimi-K2.6", "enabled":true}
],
"max_scrolls": 20,
"scroll_ratio": 0.85
}
}
GET /api/smart/config // 获取智能截图配置(含 endpoints 多接口列表)
POST /api/smart/config // 保存配置(body: {config:{endpoints:[...], prompt, max_scrolls,...}}
POST /api/smart/test // 测试指定接口(body: {endpoint_id} 或 {endpoint:{...}} 或旧版 {config:{...}}
GET /api/history?page=1&page_size=15&action=all&search=关键词 // 历史分页
GET /api/history/stats?dimension=day&days=30 // 历史统计(dimension: day/action/backend/status/caller/method
GET /api/history/&lt;id&gt; // 历史详情
GET /api/history/&lt;id&gt;/file // 历史截图文件
DELETE /api/history/&lt;id&gt; // 删除历史</code></pre>
</div>
</div>
<!-- 智能截图配置弹窗(多视觉大模型接口 + 优先级降级) -->
<div class="modal-overlay" id="smartCfgOverlay" onclick="if(event.target===this)closeSmartConfig()">
<div class="modal">
<div class="modal-header">
<h3>⚙️ 智能截图 · 视觉大模型配置</h3>
<button class="modal-close" onclick="closeSmartConfig()"></button>
</div>
<div class="modal-body">
<div style="padding:12px 16px;background:#eef2ff;border-radius:8px;font-size:13px;line-height:1.8;color:#333;margin-bottom:16px;">
🔗 接口按列表顺序作为 <strong>优先级</strong>(越靠前越优先)。调用时从高到低逐个尝试,<strong>失败/报错/超时自动降级</strong>到下一个接口,全部失败才停止滚动。
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<strong style="color:#333;">🎯 视觉大模型接口列表(均需含视觉能力)</strong>
<button class="btn-small" onclick="addSmartEndpoint()"> 添加接口</button>
</div>
<div id="epList" style="display:flex;flex-direction:column;gap:10px;"></div>
<div style="margin-top:18px;border-top:1px dashed #ddd;padding-top:16px;">
<div class="form-group" style="margin-bottom:12px;">
<label for="scPrompt">判断提示词(告诉大模型如何判断是否截全)</label>
<textarea id="scPrompt" rows="6" style="width:100%; padding:12px; border:2px solid #e0e0e0; border-radius:8px; font-size:14px; font-family:inherit; line-height:1.6;"></textarea>
</div>
<div class="row">
<div>
<label for="scMaxScrolls">最大滚动次数</label>
<input type="number" id="scMaxScrolls" min="1" max="100" value="20">
</div>
<div>
<label for="scRatio">单次滚动比例(视口高度%)</label>
<input type="number" id="scRatio" min="10" max="100" value="85">
</div>
<div>
<label for="scTimeout">LLM 超时(秒)</label>
<input type="number" id="scTimeout" min="10" max="600" value="120">
</div>
</div>
</div>
<div id="scTestResult" style="margin-top:12px; font-size:14px; line-height:1.8;"></div>
</div>
<div class="actions" style="padding: 0 24px 20px;">
<button class="btn btn-secondary" onclick="saveSmartConfig()">💾 保存配置</button>
<button class="btn btn-secondary" onclick="closeSmartConfig()">关闭</button>
</div>
</div>
</div>
<!-- 历史图示统计弹窗 -->
<div class="modal-overlay" id="statsOverlay" onclick="if(event.target===this)closeStats()">
<div class="modal">
<div class="modal-header">
<h3>📊 提取历史统计</h3>
<button class="modal-close" onclick="closeStats()"></button>
</div>
<div class="modal-body">
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px;">
<span style="font-size:13px;color:#666;font-weight:600;">角度:</span>
<div id="statDimBtns" style="display:flex;gap:6px;flex-wrap:wrap;"></div>
<span style="font-size:13px;color:#666;font-weight:600;margin-left:10px;">范围:</span>
<div id="statDaysBtns" style="display:flex;gap:6px;"></div>
<span style="font-size:13px;color:#666;font-weight:600;margin-left:10px;">图型:</span>
<div id="statTypeBtns" style="display:flex;gap:6px;"></div>
<button class="btn-small" onclick="refreshStats()" style="margin-left:auto;">🔄 刷新</button>
</div>
<div id="statSummary" style="display:flex;gap:18px;flex-wrap:wrap;margin-bottom:12px;font-size:14px;color:#333;"></div>
<div style="background:#fff;border:1px solid #eee;border-radius:10px;padding:10px;">
<canvas id="statsCanvas" style="width:100%;height:340px;display:block;"></canvas>
</div>
<div id="statTable" style="margin-top:14px;"></div>
</div>
<div class="actions" style="padding: 0 24px 20px;">
<button class="btn btn-secondary" onclick="downloadStatsPng()">💾 下载图表</button>
<button class="btn btn-secondary" onclick="closeStats()">关闭</button>
</div>
</div>
</div>
<!-- 历史详情弹窗 -->
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
@@ -755,9 +882,421 @@ DELETE /api/history/&lt;id&gt; // 删除
let currentPage = 1;
let currentHistory = { page: 1, total: 0, total_pages: 1 };
let modalRecord = null;
let smartConfig = null;
let lastSmartHistoryId = null;
// 页面加载时读取历史
window.addEventListener('load', () => loadHistory(1));
// 页面加载时读取历史 + 智能配置 + 可用后端
window.addEventListener('load', () => {
loadHistory(1);
loadSmartConfig();
loadBackends();
});
async function loadBackends() {
const sel = document.getElementById('backend');
const hint = document.getElementById('backendHint');
try {
const res = await fetch('/api/backends');
const data = await res.json();
if (!data.success) throw new Error(data.error || '加载失败');
const labels = data.labels || {};
const opts = ['auto'].concat(data.available || []);
sel.innerHTML = opts.map(v =>
`<option value="${v}">${escapeHtml(v)}${escapeHtml(labels[v] || '')}</option>`
).join('') + (data.unavailable || []).map(v =>
`<option value="${v}" disabled>${escapeHtml(v)}${escapeHtml(labels[v] || '')}(不可用)</option>`
).join('');
sel.value = 'auto';
hint.textContent = labels['auto'] || hint.textContent;
} catch (err) {
sel.innerHTML = '<option value="auto">自动</option>';
hint.textContent = '后端列表加载失败:' + err.message;
}
}
// 操作类型切换:智能模式显示提示条
document.querySelectorAll('input[name="action"]').forEach(r => {
r.addEventListener('change', () => {
const smart = document.querySelector('input[name="action"]:checked').value === 'smart';
document.getElementById('smartHint').style.display = smart ? 'block' : 'none';
});
});
/* ===== 智能截图配置(多接口 + 优先级降级) ===== */
let smartEndpoints = [];
async function loadSmartConfig() {
try {
const res = await fetch('/api/smart/config');
const data = await res.json();
if (data.success) smartConfig = data.config;
} catch (e) {}
}
function smartEndpointNewId() {
return 'ep_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
function renderSmartEndpoints() {
const list = document.getElementById('epList');
if (!smartEndpoints.length) {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#999;border:1.5px dashed #ddd;border-radius:8px;">暂无接口,点击「➕ 添加接口」添加(按需截图至少需要一个可用接口)</div>';
return;
}
list.innerHTML = smartEndpoints.map((ep, i) => `
<div style="border:1px solid #e0e0e0;border-radius:10px;padding:12px;background:#fafbff;">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
<span style="background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border-radius:50%;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex-shrink:0;" title="优先级序号(越小越优先)">${i+1}</span>
<label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer;font-weight:600;color:#333;flex-shrink:0;">
<input type="checkbox" ${ep.enabled ? 'checked' : ''} onchange="toggleSmartEndpoint('${ep.id}')">启用
</label>
<input type="text" value="${escapeHtml(ep.name || '')}" placeholder="接口名称" onchange="smartEndpointField('${ep.id}','name',this.value)" style="flex:1;min-width:140px;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
<span style="display:flex;gap:4px;flex-shrink:0;">
<button class="btn-small" onclick="moveSmartEndpoint('${ep.id}',-1)" ${i === 0 ? 'disabled' : ''} title="提升优先级">⬆</button>
<button class="btn-small" onclick="moveSmartEndpoint('${ep.id}',1)" ${i === smartEndpoints.length - 1 ? 'disabled' : ''} title="降低优先级">⬇</button>
<button class="btn-small" onclick="removeSmartEndpoint('${ep.id}')" style="background:#dc3545;" title="删除接口">🗑</button>
</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;">
<div>
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">base_url</label>
<input type="text" value="${escapeHtml(ep.base_url || '')}" placeholder="https://.../v1" onchange="smartEndpointField('${ep.id}','base_url',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">API Key</label>
<input type="text" value="${escapeHtml(ep.api_key || '')}" placeholder="sk-... 或 xxxx" onchange="smartEndpointField('${ep.id}','api_key',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">模型名称 model</label>
<input type="text" value="${escapeHtml(ep.model || '')}" placeholder="qwen3.8-27B..." onchange="smartEndpointField('${ep.id}','model',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
</div>
<div style="display:flex;align-items:flex-end;">
<button class="btn-small" onclick="testSmartEndpoint('${ep.id}')" style="width:100%;padding:9px;">🧪 测试连接</button>
</div>
</div>
<div id="epTest_${ep.id}" style="font-size:13px;line-height:1.7;margin-top:6px;"></div>
</div>
`).join('');
}
function smartEndpointField(id, key, val) {
const ep = smartEndpoints.find(e => e.id === id);
if (ep) ep[key] = val;
}
function toggleSmartEndpoint(id) {
const ep = smartEndpoints.find(e => e.id === id);
if (ep) ep.enabled = !ep.enabled;
}
function moveSmartEndpoint(id, dir) {
const i = smartEndpoints.findIndex(e => e.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= smartEndpoints.length) return;
[smartEndpoints[i], smartEndpoints[j]] = [smartEndpoints[j], smartEndpoints[i]];
renderSmartEndpoints();
}
function addSmartEndpoint() {
smartEndpoints.push({ id: smartEndpointNewId(), name: '', base_url: '', api_key: '', model: '', enabled: true });
renderSmartEndpoints();
const last = document.getElementById('epList').lastElementChild;
if (last) last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function removeSmartEndpoint(id) {
smartEndpoints = smartEndpoints.filter(e => e.id !== id);
renderSmartEndpoints();
}
async function testSmartEndpoint(id) {
const el = document.getElementById('epTest_' + id);
el.innerHTML = '<span style="color:#667eea;">⏳ 正在测试连接...</span>';
try {
const res = await fetch('/api/smart/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint_id: id })
});
const data = await res.json();
if (data.success) {
el.innerHTML = `<span style="color:#2e7d32;">✅ 连接成功!耗时 ${data.latency}s,模型 ${escapeHtml(data.model)} 回复:${escapeHtml(data.reply)}</span>`;
} else {
el.innerHTML = `<span style="color:#c62828;">❌ 测试失败:${escapeHtml(data.error || '未知错误')}</span>`;
}
} catch (err) {
el.innerHTML = `<span style="color:#c62828;">❌ 测试失败:${escapeHtml(err.message)}</span>`;
}
}
function openSmartConfig() {
if (!smartConfig) return;
smartEndpoints = (smartConfig.endpoints || []).map(ep => JSON.parse(JSON.stringify(ep)));
document.getElementById('scPrompt').value = smartConfig.prompt || '';
document.getElementById('scMaxScrolls').value = smartConfig.max_scrolls || 20;
document.getElementById('scRatio').value = Math.round((smartConfig.scroll_ratio || 0.85) * 100);
document.getElementById('scTimeout').value = smartConfig.timeout || 120;
document.getElementById('scTestResult').innerHTML = '';
renderSmartEndpoints();
document.getElementById('smartCfgOverlay').classList.add('active');
}
function closeSmartConfig() {
document.getElementById('smartCfgOverlay').classList.remove('active');
}
function collectSmartConfigForm() {
return {
endpoints: smartEndpoints.map(ep => ({
id: ep.id, name: ep.name, base_url: ep.base_url,
api_key: ep.api_key, model: ep.model, enabled: ep.enabled !== false
})),
prompt: document.getElementById('scPrompt').value.trim(),
max_scrolls: parseInt(document.getElementById('scMaxScrolls').value) || 20,
scroll_ratio: (parseInt(document.getElementById('scRatio').value) || 85) / 100,
timeout: parseInt(document.getElementById('scTimeout').value) || 120
};
}
async function saveSmartConfig() {
const cfg = collectSmartConfigForm();
const el = document.getElementById('scTestResult');
el.innerHTML = '<span style="color:#667eea;">⏳ 正在保存...</span>';
try {
const res = await fetch('/api/smart/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: cfg })
});
const data = await res.json();
if (data.success) {
smartConfig = data.config;
const enabled = (data.config.endpoints || []).filter(e => e.enabled !== false).length;
el.innerHTML = `✅ 配置已保存并生效(共 ${(data.config.endpoints || []).length} 个接口,启用 ${enabled} 个,按列表顺序作为优先级)`;
} else {
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(data.error || '未知错误')}</span>`;
}
} catch (err) {
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(err.message)}</span>`;
}
}
/* ===== 历史图示统计 ===== */
let statsDim = 'day';
let statsDays = 30;
let statsType = 'bar';
let statsData = null;
const STAT_COLORS = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#43e97b', '#fa709a', '#ffd86f', '#5ee7df', '#c471f5', '#f6d365'];
function openStats() {
document.getElementById('statsOverlay').classList.add('active');
renderStatsControls();
refreshStats();
}
function closeStats() {
document.getElementById('statsOverlay').classList.remove('active');
}
function renderStatsControls() {
const dims = [['day', '📅 按天'], ['action', '🎯 操作类型'], ['backend', '🖥 后端'], ['status', '✅ 状态'], ['caller', '👤 调用者'], ['method', '🔀 调用方式']];
document.getElementById('statDimBtns').innerHTML = dims.map(([v, l]) =>
`<button class="btn-small" style="${v === statsDim ? 'background:#667eea;' : ''}" onclick="setStatsDim('${v}')">${l}</button>`
).join('');
document.getElementById('statDaysBtns').innerHTML = [7, 30, 90].map(d =>
`<button class="btn-small" style="${d === statsDays ? 'background:#667eea;' : ''}" onclick="setStatsDays(${d})">${d}天</button>`
).join('');
document.getElementById('statTypeBtns').innerHTML = [['pie', '🍩 饼图'], ['bar', '📊 柱状']].map(([t, l]) =>
`<button class="btn-small" style="${t === statsType ? 'background:#667eea;' : ''}" onclick="setStatsType('${t}')">${l}</button>`
).join('');
}
function setStatsDim(d) {
statsDim = d;
statsType = d === 'day' ? 'bar' : 'pie'; // 维度切换时默认图型
renderStatsControls();
refreshStats();
}
function setStatsDays(d) { statsDays = d; renderStatsControls(); refreshStats(); }
function setStatsType(t) { statsType = t; renderStatsControls(); renderStatsChart(); }
async function refreshStats() {
try {
const res = await fetch(`/api/history/stats?dimension=${statsDim}&days=${statsDays}`);
const data = await res.json();
if (!data.success) throw new Error(data.error || '加载失败');
statsData = data;
renderStatsSummary();
renderStatsChart();
renderStatsTable();
} catch (err) {
document.getElementById('statSummary').innerHTML = `<span style="color:#c62828;">❌ 加载统计失败:${escapeHtml(err.message)}</span>`;
}
}
function renderStatsSummary() {
if (!statsData) return;
const rate = (statsData.success_rate * 100).toFixed(1);
const dimLabel = {day: '近', action: '', backend: '', status: '', caller: '', method: ''}[statsData.dimension];
document.getElementById('statSummary').innerHTML =
`<span>📈 统计总数:<strong>${statsData.total}</strong> 条</span>` +
`<span>✅ 成功:<strong>${statsData.success_count}</strong> 条(成功率 ${rate}%</span>` +
(statsData.dimension === 'day' ? `<span>📅 近 <strong>${statsData.days}</strong> 天提取量</span>` : '');
}
function renderStatsChart() {
const canvas = document.getElementById('statsCanvas');
if (!statsData || !statsData.items || !statsData.items.length) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr; canvas.height = 340 * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, 340);
ctx.fillStyle = '#999'; ctx.font = '15px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('暂无数据', rect.width / 2, 170);
return;
}
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const W = Math.max(200, rect.width), H = 340;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
if (statsType === 'pie') drawStatsPie(ctx, W, H, statsData.items);
else drawStatsBar(ctx, W, H, statsData.items, statsData.dimension);
}
function drawStatsPie(ctx, W, H, items) {
const total = items.reduce((s, it) => s + it.value, 0);
if (!total) return;
const legendW = 300;
const cx = Math.min(W - legendW, W * 0.42) / 2 + 40, cy = H / 2;
const r = Math.min(H / 2 - 30, (W - legendW) / 2 - 60);
let angle = -Math.PI / 2;
items.forEach((it, i) => {
const a = it.value / total * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, angle, angle + a);
ctx.closePath();
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
angle += a;
});
// 中心文字
ctx.fillStyle = '#333'; ctx.textAlign = 'center';
ctx.font = 'bold 28px sans-serif'; ctx.fillText(String(total), cx, cy - 4);
ctx.font = '13px sans-serif'; ctx.fillStyle = '#999'; ctx.fillText('总提取量', cx, cy + 22);
// 图例
let lx = cx + r + 28, ly = H / 2 - ((items.length - 1) * 26) / 2;
ctx.textAlign = 'left';
items.forEach((it, i) => {
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
ctx.fillRect(lx, ly - 8, 14, 14);
const pct = (it.value / total * 100).toFixed(1);
const label = it.label.length > 16 ? it.label.slice(0, 15) + '…' : it.label;
ctx.fillStyle = '#333'; ctx.font = '13px sans-serif';
ctx.fillText(`${label} ${it.value} (${pct}%)`, lx + 20, ly + 3);
ly += 26;
});
}
function drawStatsBar(ctx, W, H, items, dimension) {
if (dimension === 'day') {
drawDayBar(ctx, W, H, items);
} else {
drawCatBar(ctx, W, H, items);
}
}
function drawDayBar(ctx, W, H, items) {
const left = 52, right = 16, top = 18, bottom = 34;
const cw = W - left - right, ch = H - top - bottom;
const max = Math.max(1, ...items.map(it => it.value));
// 网格线 + Y 轴
ctx.strokeStyle = '#eee'; ctx.fillStyle = '#999'; ctx.font = '11px sans-serif'; ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const v = Math.round(max * i / 4);
const y = top + ch - ch * i / 4;
ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(W - right, y); ctx.stroke();
ctx.fillText(String(v), left - 6, y + 4);
}
const n = items.length;
const step = Math.max(1, Math.ceil(n / Math.max(6, Math.floor(cw / 60))));
const bw = cw / n;
items.forEach((it, i) => {
const h = Math.max(0, ch * it.value / max);
const x = left + i * bw + bw * 0.15, w = Math.max(2, bw * 0.7);
const g = ctx.createLinearGradient(0, top + ch - h, 0, top + ch);
g.addColorStop(0, '#667eea'); g.addColorStop(1, '#a78bfa');
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(x, top + ch - h, w, h, 3);
ctx.fill();
if (it.value > 0) {
ctx.fillStyle = '#555'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(String(it.value), x + w / 2, top + ch - h - 3);
}
// X 轴日期标签(稀疏)
if (i % step === 0) {
ctx.fillStyle = '#888'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.save();
ctx.translate(x + w / 2, top + ch + 10);
ctx.rotate(-0.5);
ctx.fillText(it.label.slice(5), 0, 0); // MM-DD
ctx.restore();
}
});
}
function drawCatBar(ctx, W, H, items) {
const maxLabel = Math.max(...items.map(it => it.label.length));
const left = Math.min(W * 0.4, Math.max(120, maxLabel * 13 + 20));
const right = 70, top = 12, bottom = 16;
const cw = W - left - right, ch = H - top - bottom;
const max = Math.max(1, ...items.map(it => it.value));
const rowH = Math.min(40, ch / items.length);
const startY = top + (ch - rowH * items.length) / 2;
items.forEach((it, i) => {
const y = startY + i * rowH;
const bw = Math.max(2, cw * it.value / max);
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
ctx.beginPath();
ctx.roundRect(left, y + 6, bw, rowH - 12, 4);
ctx.fill();
const label = it.label.length > 14 ? it.label.slice(0, 13) + '…' : it.label;
ctx.fillStyle = '#333'; ctx.font = '13px sans-serif'; ctx.textAlign = 'right';
ctx.fillText(label, left - 8, y + rowH / 2 + 4);
ctx.fillStyle = '#555'; ctx.textAlign = 'left';
ctx.fillText(String(it.value), left + bw + 8, y + rowH / 2 + 4);
});
}
function renderStatsTable() {
const el = document.getElementById('statTable');
if (!statsData || !statsData.items || !statsData.items.length) { el.innerHTML = ''; return; }
const items = statsData.items;
if (items.length > 12) { el.innerHTML = ''; return; } // 分类多/按天时不渲染表,避免太长
const total = items.reduce((s, it) => s + it.value, 0) || 1;
el.innerHTML = `<table class="history-table" style="font-size:13px;">
<thead><tr><th>项</th><th>数量</th><th>占比</th></tr></thead>
<tbody>${items.map(it =>
`<tr><td>${escapeHtml(it.label)}</td><td><strong>${it.value}</strong></td><td>${(it.value / total * 100).toFixed(1)}%</td></tr>`
).join('')}</tbody></table>`;
}
function downloadStatsPng() {
const canvas = document.getElementById('statsCanvas');
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = `stats_${statsDim}_${statsDays}d_${new Date().toISOString().slice(0, 10)}.png`;
a.click();
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -778,10 +1317,12 @@ DELETE /api/history/&lt;id&gt; // 删除
scroll_delay: scrollDelay,
full_page: fullPage,
wait_time: waitTime,
backend: document.getElementById('backend').value || 'auto',
viewport: {
width: viewportWidth,
height: viewportHeight
}
},
smart_config: action === 'smart' ? (smartConfig || {}) : undefined
};
// 显示加载
@@ -794,7 +1335,8 @@ DELETE /api/history/&lt;id&gt; // 删除
const response = await fetch('/api/capture', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-From': 'web'
},
body: JSON.stringify(currentData)
});
@@ -815,6 +1357,17 @@ DELETE /api/history/&lt;id&gt; // 删除
currentBlob = new Blob([data.html], { type: 'text/html' });
document.getElementById('resultContent').innerHTML =
`<div class="result-code"><pre>${escapeHtml(data.html)}</pre></div>`;
} else if (action === 'smart') {
lastSmartHistoryId = data.history_id || null;
currentBlob = new Blob([JSON.stringify(data.steps || [], null, 2)], { type: 'application/json' });
const stepsHtml = (data.steps || []).map(s =>
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}${s.model ? `<span style="color:#888;font-size:12px;">(模型:${escapeHtml(s.model)}</span>${s.endpoint ? `<span style="color:#888;font-size:12px;"> / ${escapeHtml(s.endpoint)}</span>` : ''}<span style="color:#888;font-size:12px;"></span>` : ''}</li>`
).join('');
document.getElementById('resultContent').innerHTML =
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} 🧠 AI 共滚动 <strong>${data.total_steps || 0}</strong> 次判定完成,已拼接为长图(下方展示)</div>` +
`<img src="/api/history/${data.history_id}/file" class="result-image" alt="智能截图长图" onerror="this.style.display='none'">` +
`<details style="margin-top:12px;"><summary style="cursor:pointer;color:#667eea;font-weight:600;">📋 查看 AI 判断过程(${(data.steps || []).length} 步)</summary>` +
`<ul style="margin:10px 0 0 20px;line-height:1.8;color:#333;font-size:14px;">${stepsHtml}</ul></details>`;
} else {
currentBlob = new Blob([data.text], { type: 'text/plain;charset=utf-8' });
const lines = (data.text || '').split('\n').length;
@@ -843,6 +1396,16 @@ DELETE /api/history/&lt;id&gt; // 删除
}
function downloadResult() {
if (!currentData) return;
if (currentData.action === 'smart') {
if (lastSmartHistoryId) {
const a = document.createElement('a');
a.href = `/api/history/${lastSmartHistoryId}/file`;
a.download = 'smart_capture.png';
a.click();
}
return;
}
if (!currentBlob) return;
const url = URL.createObjectURL(currentBlob);
@@ -872,6 +1435,7 @@ DELETE /api/history/&lt;id&gt; // 删除
error.classList.remove('active');
currentData = null;
currentBlob = null;
lastSmartHistoryId = null;
}
/* ===== 历史记录 ===== */
@@ -916,7 +1480,8 @@ DELETE /api/history/&lt;id&gt; // 删除
const badges = {
screenshot: '📸 截图',
html: '📄 HTML',
text: '📝 文本'
text: '📝 文本',
smart: '🧠 按需截图'
};
body.innerHTML = data.records.map(r => {
@@ -925,9 +1490,14 @@ DELETE /api/history/&lt;id&gt; // 删除
const statusText = r.status === 'failed'
? `<span style="color:#c62828;" title="${escapeHtml(r.error || '')}">失败</span>`
: '<span style="color:#2e7d32;">成功</span>';
const callerText = r.caller || '游客';
const methodText = r.call_method || 'api';
const methodBadge = methodText === 'web' ? 'badge-web' : 'badge-api';
return `<tr>
<td style="white-space:nowrap;">${escapeHtml(r.created_at)}</td>
<td><span class="badge ${escapeHtml(badgeClass)}">${badgeText}</span></td>
<td><span class="history-title" title="${escapeHtml(callerText)}">${escapeHtml(callerText)}</span></td>
<td><span class="badge ${methodBadge}" style="font-size:11px;padding:2px 8px;">${escapeHtml(methodText)}</span></td>
<td><a class="history-url" href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.url)}</a></td>
<td><span class="history-title" title="${escapeHtml(r.title || '')}">${escapeHtml(r.title || '—')}</span></td>
<td>${statusText}</td>
@@ -987,11 +1557,17 @@ DELETE /api/history/&lt;id&gt; // 删除
document.getElementById('modalTitle').textContent = (r.title || r.url || '记录详情');
const badgeClass = r.status === 'failed' ? 'badge-failed' : ('badge-' + r.action);
const badgeText = r.status === 'failed' ? '❌ 失败' : ({screenshot: '📸 截图', html: '📄 HTML', text: '📝 文本'}[r.action] || r.action);
const htmlLink = r.html_path
? `<span><a href="/api/history/${r.id}/html" target="_blank" style="color:#667eea;font-weight:600;" title="${escapeHtml(r.html_path)}">📄 原始HTML文件</a></span>`
: '';
document.getElementById('modalMeta').innerHTML =
`<span>类型:<span class="badge ${badgeClass}">${badgeText}</span></span>` +
`<span>时间:${escapeHtml(r.created_at)}</span>` +
`<span>调用者:${escapeHtml(r.caller || '游客')}</span>` +
`<span>方式:${escapeHtml(r.call_method || 'api')}</span>` +
`<span>后端:${escapeHtml(r.backend || '—')}</span>` +
`<span>状态:${r.status === 'failed' ? '<span style="color:#c62828;">失败</span>' : '<span style="color:#2e7d32;">成功</span>'}</span>`;
`<span>状态:${r.status === 'failed' ? '<span style="color:#c62828;">失败</span>' : '<span style="color:#2e7d32;">成功</span>'}</span>` +
htmlLink;
const body = document.getElementById('modalBody');
if (r.status === 'failed') {
@@ -1000,6 +1576,19 @@ DELETE /api/history/&lt;id&gt; // 删除
body.innerHTML = `<img src="/api/history/${r.id}/file" class="result-image" alt="历史截图" onerror="this.parentElement.innerHTML='&lt;p style=&quot;color:#c62828;&quot;&gt;截图文件不存在或已删除&lt;/p&gt;'">`;
} else if (r.action === 'html') {
body.innerHTML = `<div class="result-code"><pre>${escapeHtml(r.content || '')}</pre></div>`;
} else if (r.action === 'smart') {
let stepsHtml = '';
let stepsCount = 0;
try {
const steps = JSON.parse(r.content || '[]');
stepsCount = steps.length;
stepsHtml = steps.map(s =>
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}${s.model ? `<span style="color:#888;font-size:12px;">(模型:${escapeHtml(s.model)}</span>${s.endpoint ? `<span style="color:#888;font-size:12px;"> / ${escapeHtml(s.endpoint)}</span>` : ''}<span style="color:#888;font-size:12px;"></span>` : ''}</li>`
).join('');
} catch (e) {}
body.innerHTML =
`<img src="/api/history/${r.id}/file" class="result-image" alt="智能截图长图" onerror="this.style.display='none'">` +
(stepsHtml ? `<details open style="margin-top:14px;"><summary style="cursor:pointer;color:#667eea;font-weight:600;">📋 AI 判断过程(${stepsCount} 步)</summary><ul style="margin:10px 0 0 20px;line-height:1.8;color:#333;font-size:14px;">${stepsHtml}</ul></details>` : '');
} else {
body.innerHTML = `<div class="result-text">${escapeHtml(r.content || '')}</div>`;
}
@@ -1037,10 +1626,10 @@ DELETE /api/history/&lt;id&gt; // 删除
function downloadHistoryContent() {
if (!modalRecord) return;
if (modalRecord.action === 'screenshot') {
if (modalRecord.action === 'screenshot' || modalRecord.action === 'smart') {
const a = document.createElement('a');
a.href = `/api/history/${modalRecord.id}/file`;
a.download = `capture_${modalRecord.id}.png`;
a.download = modalRecord.action === 'smart' ? `smart_${modalRecord.id}.png` : `capture_${modalRecord.id}.png`;
a.click();
return;
}