Files
web-capture-api/templates/index.html
T
hz4th_coder 96b7b5dd7a v1.9.2 缓存默认时效改为7天 + 支持-1永久缓存 + 重试间隔默认15秒
1) 缓存时效: 默认 ttl_seconds 3600→604800(7天); ttl_seconds=-1 表示永久有效(不按时间过滤, 历史有成功记录就一直命中), 全局配置与 per-request cache_ttl_seconds 均支持
2) 重试间隔: 默认 delay_seconds 2→15秒
3) 前端: 徽章/弹窗显示7天或永久(fmtTtl), 有效期输入框支持-1, 保存换算-1原样存储, API文档同步(-1=永久说明)
验证: 7天TTL命中今日记录 / ttl=-1命中超7天旧记录(0.0098s返回PNG, 源站已挂仍可用) / 恢复默认
2026-09-11 23:13:26 +08:00

1814 lines
85 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Capture - 网页截图/HTML/文本提取</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
}
.card {
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
}
.radio-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.radio-label input[type="radio"] {
width: 20px;
height: 20px;
}
.checkbox-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 20px;
height: 20px;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 40px;
font-size: 18px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
margin-top: 10px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.loading {
display: none;
text-align: center;
padding: 20px;
}
.loading.active {
display: block;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.result {
display: none;
}
.result.active {
display: block;
}
.result-image {
width: 100%;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.result-code {
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
max-height: 600px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
}
.result-code pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.result-text {
background: #f8f9fa;
border: 1px solid #e0e0e0;
color: #222;
padding: 20px;
border-radius: 8px;
overflow: auto;
max-height: 600px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-secondary {
background: #6c757d;
padding: 10px 20px;
font-size: 14px;
width: auto;
margin-top: 0;
}
.btn-small {
background: #667eea;
color: white;
border: none;
padding: 6px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-small:hover {
background: #5568d8;
}
.btn-small.danger {
background: #dc3545;
}
.btn-small.danger:hover {
background: #c82333;
}
.btn-small:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
background: #fee;
border: 1px solid #fcc;
color: #c00;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
}
.error.active {
display: block;
}
/* ===== 历史记录区域 ===== */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 20px;
}
.history-header h3 {
font-size: 1.4em;
color: #333;
}
.history-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.history-toolbar select,
.history-toolbar input[type="text"] {
width: auto;
min-width: 120px;
padding: 8px 12px;
font-size: 14px;
}
.history-toolbar .search-input {
min-width: 220px;
}
.history-count {
color: #666;
font-size: 14px;
}
.history-table {
width: 100%;
border-collapse: collapse;
}
.history-table th,
.history-table td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: middle;
}
.history-table th {
font-size: 13px;
color: #666;
font-weight: 600;
white-space: nowrap;
}
.history-table td {
font-size: 14px;
}
.history-table tr:hover td {
background: #f8f9ff;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badge-screenshot {
background: #e3f2fd;
color: #1565c0;
}
.badge-html {
background: #fff3e0;
color: #e65100;
}
.badge-text {
background: #e8f5e9;
color: #2e7d32;
}
.badge-failed {
background: #fdecea;
color: #c62828;
}
.badge-api {
background: #ede7f6;
color: #5e35b1;
}
.badge-web {
background: #e0f7fa;
color: #00695c;
}
.history-url {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1565c0;
text-decoration: none;
display: inline-block;
vertical-align: bottom;
}
.history-title {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #333;
display: inline-block;
vertical-align: bottom;
}
.history-empty {
text-align: center;
padding: 40px;
color: #999;
font-size: 15px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
flex-wrap: wrap;
}
.page-btn {
min-width: 36px;
padding: 7px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #333;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
border-color: #667eea;
color: #667eea;
}
.page-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-info {
color: #666;
font-size: 14px;
white-space: nowrap;
}
/* ===== 查看详情弹窗 ===== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
padding: 30px;
overflow-y: auto;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: white;
border-radius: 14px;
width: 100%;
max-width: 900px;
margin: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
gap: 15px;
flex-wrap: wrap;
}
.modal-header h3 {
font-size: 16px;
color: #333;
word-break: break-all;
}
.modal-meta {
padding: 10px 24px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
font-size: 13px;
color: #666;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.modal-body {
padding: 20px 24px;
max-height: 70vh;
overflow: auto;
}
.modal-body .result-image {
width: 100%;
}
.modal-close {
background: #6c757d;
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 18px;
cursor: pointer;
line-height: 1;
flex-shrink: 0;
}
.modal-close:hover {
background: #545b62;
}
.api-docs {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.api-docs h3 {
margin-bottom: 15px;
color: #333;
}
.api-docs code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.api-docs pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin-top: 10px;
}
@media (max-width: 700px) {
.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 th:nth-child(4),
.history-table td:nth-child(4) {
display: none;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌐 Web Capture</h1>
<p>网页截图 / HTML / 可读文本 提取服务</p>
</div>
<div class="card">
<form id="captureForm">
<div class="form-group">
<label for="url">目标网址</label>
<input type="text" id="url" placeholder="https://example.com" required>
</div>
<div class="form-group">
<label>操作类型</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="action" value="screenshot" checked>
📸 截图
</label>
<label class="radio-label">
<input type="radio" name="action" value="html">
📄 提取HTML
</label>
<label class="radio-label">
<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>
<button type="button" class="btn-small" onclick="openCaptureCfg()" 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>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="fullPage">
全页截图
</label>
<label class="checkbox-label">
<input type="checkbox" id="forceRefresh">
🔄 强制刷新(绕过网址缓存,重新提取)
</label>
</div>
<small style="color: #666; display: block; margin-top: 6px; line-height: 1.8;">
📦 网址缓存:<span id="cacheStateBadge"></span> 有效期 <span id="cacheTtlBadge"></span>
<button type="button" class="btn-small" onclick="openCaptureCfg()" style="margin-left:8px;padding:3px 10px;font-size:12px;vertical-align:middle;" title="打开缓存/重试设置">⚙️ 设置时效与开关</button><br>
<span style="color:#999;">同网址命中缓存直接返回历史结果,不重复提取;勾选上方「强制刷新」可绕过。</span>
</small>
</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">
<div>
<label for="waitTime">页面加载等待(ms) ⏱️</label>
<input type="number" id="waitTime" value="2000" min="0" max="60000" step="500">
<small style="color: #666; display: block; margin-top: 4px;">某些网站需要较长验证过程,可设置为 10000-30000</small>
</div>
<div>
<label for="scrollTimes">滚动次数(可选)</label>
<input type="number" id="scrollTimes" value="0" min="0" max="20">
</div>
<div>
<label for="scrollDelay">滚动间隔(ms)</label>
<input type="number" id="scrollDelay" value="1000" min="500" max="5000" step="100">
</div>
</div>
</div>
<div class="form-group">
<label>视口大小</label>
<div class="row">
<div>
<label for="viewportWidth">宽度</label>
<input type="number" id="viewportWidth" value="1920" min="320" max="3840">
</div>
<div>
<label for="viewportHeight">高度</label>
<input type="number" id="viewportHeight" value="1080" min="240" max="2160">
</div>
</div>
</div>
<button type="submit" class="btn" id="submitBtn">
🚀 开始捕获
</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>正在处理,请稍候...</p>
</div>
<div class="error" id="error"></div>
<div class="result" id="result">
<h3 style="margin-bottom: 15px;">捕获结果</h3>
<div id="resultContent"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="downloadResult()">💾 下载</button>
<button class="btn btn-secondary" onclick="copyResult()">📋 复制</button>
<button class="btn btn-secondary" onclick="resetForm()">🔄 重新捕获</button>
</div>
</div>
</div>
<div class="card">
<div class="history-header">
<h3>📜 提取历史</h3>
<div class="history-toolbar">
<select id="historyActionFilter" onchange="loadHistory(1)">
<option value="all">全部类型</option>
<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>
<div class="history-count" id="historyCount"></div>
<div style="overflow-x: auto; margin-top: 10px;">
<table class="history-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>调用者</th>
<th>方式</th>
<th>网址</th>
<th>标题</th>
<th>状态</th>
<th style="text-align: right;">操作</th>
</tr>
</thead>
<tbody id="historyBody"></tbody>
</table>
</div>
<div class="history-empty" id="historyEmpty" style="display: none;">暂无提取记录,先去上方提取一个网页吧 🚀</div>
<div class="pagination" id="pagination"></div>
</div>
<div class="card api-docs">
<h3>📚 使用说明</h3>
<p><strong>重要提示:</strong></p>
<ul style="margin: 10px 0; line-height: 1.8;">
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图;支持<strong>多视觉大模型接口配置</strong>(「⚙️ 智能配置」中增删改,列表顺序=优先级,失败/报错自动降级到下一个接口)</li>
<li>📦 <strong>网址缓存</strong>:默认开启——同一网址先查提取历史(同动作、成功、在有效期内)直接返回,不重复提取;有效期默认 <strong>7 天</strong>,填 <strong>-1 则永久有效</strong>。可「⚙️ 缓存/重试」关开关/改有效期,或请求带 <code>refresh:true</code> 强制绕过</li>
<li>🔁 <strong>失败自动重试</strong>:提取失败(网络/超时/浏览器错误)自动重试,次数可设(默认 2 次,间隔 15 秒),响应带 <code>attempts</code> 字段</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>:每次提取自动入库,支持分页浏览、按类型筛选、搜索与删除;每条记录会标注<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": "smart", // "screenshot" | "html" | "text" | "smart"
"wait_time": 15000, // 重要!验证网站需设置较长等待
"scroll_times": 3, // 可选:加载动态内容
"scroll_delay": 1000, // 可选:滚动间隔
"full_page": true, // 可选:全页截图
"backend": "auto", // 可选:auto(默认,优先playwright,失败回退agent-browser) / playwright / agent-browser / chrome-cdp
"caller": "news-tracker", // 可选:调用者标识(历史记录里显示,默认游客)
"call_method": "api", // 可选:调用方式 web/api(缺省自动判定)
"viewport": {"width":1280,"height":700},
// ---- 📦 网址缓存(默认开启,全局配置在 /api/capture/settings----
"refresh": false, // 可选:true=强制实时提取(绕过缓存)
"cache": true, // 可选:false=本次请求禁用缓存
"cache_ttl_seconds": 604800, // 可选:本次缓存时效(秒, -1=永久),覆盖全局有效期,命中判定也用它
"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
}
}
// 缓存命中响应(text/html/smart 为 JSONscreenshot 直接返回历史图片文件):
// {success:true, from_cache:true, history_id:123, cached_at:"2026-09-11 22:49:41", cache_ttl_seconds:3600, ...}
📦 缓存/🔁 重试全局配置(页面「⚙️ 缓存/重试」按钮或以下接口):
GET /api/capture/settings
POST /api/capture/settings
{
"settings": {
"cache": {"enabled": true, "ttl_seconds": 604800}, // enabled=缓存开关, ttl_seconds=有效期(秒, 默认7天=604800, -1=永久有效)
"retry": {"max_retries": 2, "delay_seconds": 15} // max_retries=失败重试次数(默认2), delay_seconds=重试间隔(秒, 默认15)
}
}
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="captureCfgOverlay" onclick="if(event.target===this)closeCaptureCfg()">
<div class="modal">
<div class="modal-header">
<h3>⚙️ 网址缓存 & 失败重试</h3>
<button class="modal-close" onclick="closeCaptureCfg()"></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="padding:14px;border:1px solid #e0e0e0;border-radius:10px;margin-bottom:16px;background:#fafbff;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<strong style="color:#333;flex-shrink:0;">📦 启用网址缓存</strong>
<label class="checkbox-label" style="font-size:14px;">
<input type="checkbox" id="cacheEnabled"> 开/关
</label>
</div>
<div style="display:flex;gap:14px;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;">
<label for="cacheTtl" style="font-size:13px;">缓存有效期(分钟,-1 = 永久有效)</label>
<input type="number" id="cacheTtl" min="-1" max="525600" value="10080" style="width:100%;padding:10px;border:1.5px solid #e0e0e0;border-radius:6px;">
<small style="color:#999;display:block;margin-top:4px;">默认 7 天(10080 分钟);填 <strong>-1</strong> 则缓存永久有效,只要历史里有该网址的成功记录就一直命中,不再重新提取。</small>
</div>
</div>
</div>
<div style="padding:14px;border:1px solid #e0e0e0;border-radius:10px;background:#fafbff;">
<div style="font-size:13px;line-height:1.8;color:#333;margin-bottom:12px;">
🔁 <strong>失败重试</strong>:提取失败(网络/超时/浏览器错误)时自动重试,重试次数可设置。
</div>
<div style="display:flex;gap:14px;flex-wrap:wrap;">
<div style="flex:1;min-width:150px;">
<label for="retryCount" style="font-size:13px;">重试次数(默认 2</label>
<input type="number" id="retryCount" min="0" max="10" value="2" style="width:100%;padding:10px;border:1.5px solid #e0e0e0;border-radius:6px;">
<small style="color:#999;display:block;margin-top:4px;">0 = 不重试。2 即最多尝试 3 次。</small>
</div>
<div style="flex:1;min-width:150px;">
<label for="retryDelay" style="font-size:13px;">重试间隔(秒,默认 15</label>
<input type="number" id="retryDelay" min="0" max="300" value="15" style="width:100%;padding:10px;border:1.5px solid #e0e0e0;border-radius:6px;">
</div>
</div>
</div>
<div id="captureCfgResult" 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="saveCaptureCfg()">💾 保存设置</button>
<button class="btn btn-secondary" onclick="closeCaptureCfg()">关闭</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">
<div class="modal-header">
<h3 id="modalTitle">记录详情</h3>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-meta" id="modalMeta"></div>
<div class="modal-body" id="modalBody"></div>
<div class="actions" style="padding: 0 24px 20px;">
<button class="btn btn-secondary" onclick="downloadHistoryContent()">💾 下载内容</button>
<button class="btn btn-secondary" onclick="copyHistoryContent()">📋 复制内容</button>
</div>
</div>
</div>
<script>
const form = document.getElementById('captureForm');
const loading = document.getElementById('loading');
const result = document.getElementById('result');
const error = document.getElementById('error');
const submitBtn = document.getElementById('submitBtn');
let currentData = null;
let currentBlob = null;
let currentPage = 1;
let currentHistory = { page: 1, total: 0, total_pages: 1 };
let modalRecord = null;
let smartConfig = null;
let lastSmartHistoryId = null;
let captureSettings = { cache: { enabled: true, ttl_seconds: 3600 }, retry: { max_retries: 2, delay_seconds: 2 } };
// 页面加载时读取历史 + 智能配置 + 可用后端 + 缓存/重试设置
window.addEventListener('load', () => {
loadHistory(1);
loadSmartConfig();
loadBackends();
loadCaptureSettings();
});
function fmtTtl(sec) {
if (sec < 0) return '永久';
const min = Math.round(sec / 60);
if (min >= 1440) return (min / 1440).toFixed(1).replace(/\.0$/, '') + ' 天';
return min + ' 分钟';
}
function updateCacheBadge() {
const en = captureSettings.cache && captureSettings.cache.enabled;
document.getElementById('cacheStateBadge').textContent = en ? '✅ 开启' : '⛔ 关闭';
document.getElementById('cacheStateBadge').style.color = en ? '#2e7d32' : '#c62828';
document.getElementById('cacheTtlBadge').textContent = fmtTtl((captureSettings.cache && captureSettings.cache.ttl_seconds) || 3600);
}
async function loadCaptureSettings() {
try {
const res = await fetch('/api/capture/settings');
const data = await res.json();
if (data.success) captureSettings = data.settings;
} catch (e) {}
updateCacheBadge();
}
function openCaptureCfg() {
const ttlSec = (captureSettings.cache && captureSettings.cache.ttl_seconds != null) ? captureSettings.cache.ttl_seconds : 604800;
document.getElementById('cacheEnabled').checked = !!(captureSettings.cache && captureSettings.cache.enabled);
document.getElementById('cacheTtl').value = ttlSec < 0 ? -1 : Math.round(ttlSec / 60);
document.getElementById('retryCount').value = (captureSettings.retry && captureSettings.retry.max_retries != null) ? captureSettings.retry.max_retries : 2;
document.getElementById('retryDelay').value = (captureSettings.retry && captureSettings.retry.delay_seconds != null) ? captureSettings.retry.delay_seconds : 15;
document.getElementById('captureCfgResult').innerHTML = '';
document.getElementById('captureCfgOverlay').classList.add('active');
}
function closeCaptureCfg() {
document.getElementById('captureCfgOverlay').classList.remove('active');
}
async function saveCaptureCfg() {
const el = document.getElementById('captureCfgResult');
const ttlMin = parseInt(document.getElementById('cacheTtl').value);
const ttlSec = ttlMin === -1 ? -1 : (Math.max(1, ttlMin || 1)) * 60;
const settings = {
cache: {
enabled: document.getElementById('cacheEnabled').checked,
ttl_seconds: ttlSec
},
retry: {
max_retries: Math.max(0, parseInt(document.getElementById('retryCount').value) || 0),
delay_seconds: Math.max(0, parseInt(document.getElementById('retryDelay').value) || 0)
}
};
el.innerHTML = '<span style="color:#667eea;">⏳ 正在保存...</span>';
try {
const res = await fetch('/api/capture/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings })
});
const data = await res.json();
if (data.success) {
captureSettings = data.settings;
updateCacheBadge();
const en = data.settings.cache.enabled;
el.innerHTML = `✅ 已保存:网址缓存${en ? '开启' : '关闭'}(有效期 ${fmtTtl(data.settings.cache.ttl_seconds)})| 失败重试 ${data.settings.retry.max_retries} 次(间隔 ${data.settings.retry.delay_seconds}s`;
} else {
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(data.error || '未知错误')}</span>`;
}
} catch (err) {
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(err.message)}</span>`;
}
}
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();
const url = document.getElementById('url').value;
const action = document.querySelector('input[name="action"]:checked').value;
const fullPage = document.getElementById('fullPage').checked;
const scrollTimes = parseInt(document.getElementById('scrollTimes').value);
const scrollDelay = parseInt(document.getElementById('scrollDelay').value);
const waitTime = parseInt(document.getElementById('waitTime').value);
const viewportWidth = parseInt(document.getElementById('viewportWidth').value);
const viewportHeight = parseInt(document.getElementById('viewportHeight').value);
currentData = {
url,
action,
refresh: document.getElementById('forceRefresh').checked,
scroll_times: scrollTimes,
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
};
// 显示加载
loading.classList.add('active');
result.classList.remove('active');
error.classList.remove('active');
submitBtn.disabled = true;
try {
const response = await fetch('/api/capture', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-From': 'web'
},
body: JSON.stringify(currentData)
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || '请求失败');
}
if (action === 'screenshot') {
currentBlob = await response.blob();
const imageUrl = URL.createObjectURL(currentBlob);
document.getElementById('resultContent').innerHTML =
`<img src="${imageUrl}" class="result-image" alt="截图结果">`;
} else {
const data = await response.json();
const cacheTag = data.from_cache
? `<div style="margin-bottom:10px;padding:8px 14px;background:#fff8e1;border:1px solid #ffe082;border-radius:8px;font-size:13px;color:#795548;">📦 缓存命中:直接返回历史记录(#${data.history_id},提取于 ${escapeHtml(data.cached_at || '')},缓存有效期 ${data.cache_ttl_seconds / 60} 分钟)。勾选「强制刷新」可绕过缓存重新提取。</div>`
: '';
if (action === 'html') {
currentBlob = new Blob([data.html], { type: 'text/html' });
document.getElementById('resultContent').innerHTML =
cacheTag +
`<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 =
cacheTag +
`<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;
document.getElementById('resultContent').innerHTML =
cacheTag +
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} ${lines} 行,${(data.text || '').length} 字符(已剔除标签与无效字符)${data.from_cache ? ` 📦 缓存命中(历史 #${data.history_id}` : ''}</div>` +
`<div class="result-text">${escapeHtml(data.text || '')}</div>`;
}
}
result.classList.add('active');
// 捕获成功后刷新历史(回到第一页看最新)
loadHistory(1);
} catch (err) {
error.textContent = '❌ ' + err.message;
error.classList.add('active');
} finally {
loading.classList.remove('active');
submitBtn.disabled = false;
}
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
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);
const a = document.createElement('a');
a.href = url;
const name = currentData.action === 'screenshot' ? 'screenshot.png'
: currentData.action === 'html' ? 'page.html' : 'page.txt';
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
function copyResult() {
if (!currentBlob) return;
const reader = new FileReader();
reader.onload = () => {
navigator.clipboard.writeText(reader.result).then(() => {
alert('已复制到剪贴板');
});
};
reader.readAsText(currentBlob);
}
function resetForm() {
result.classList.remove('active');
error.classList.remove('active');
currentData = null;
currentBlob = null;
lastSmartHistoryId = null;
}
/* ===== 历史记录 ===== */
async function loadHistory(page) {
const action = document.getElementById('historyActionFilter').value;
const search = document.getElementById('historySearch').value.trim();
const qs = new URLSearchParams({ page: page, page_size: 15, action, search });
const res = await fetch('/api/history?' + qs.toString());
const data = await res.json();
if (!data.success) {
document.getElementById('historyEmpty').style.display = 'block';
document.getElementById('historyEmpty').textContent = '❌ 加载历史失败:' + (data.error || '未知错误');
return;
}
currentPage = page;
currentHistory = data;
renderHistory(data);
}
function refreshHistory() {
loadHistory(currentPage);
}
function renderHistory(data) {
const body = document.getElementById('historyBody');
const empty = document.getElementById('historyEmpty');
const count = document.getElementById('historyCount');
count.textContent = `共 ${data.total} 条记录`;
if (!data.records.length) {
body.innerHTML = '';
empty.style.display = 'block';
empty.textContent = '暂无提取记录,先去上方提取一个网页吧 🚀';
renderPagination(data);
return;
}
empty.style.display = 'none';
const badges = {
screenshot: '📸 截图',
html: '📄 HTML',
text: '📝 文本',
smart: '🧠 按需截图'
};
body.innerHTML = data.records.map(r => {
const badgeClass = r.status === 'failed' ? 'badge-failed' : ('badge-' + r.action);
const badgeText = r.status === 'failed' ? '❌ 失败' : (badges[r.action] || r.action);
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>
<td style="text-align:right; white-space:nowrap;">
<button class="btn-small" onclick="viewRecord(${r.id})">👁 查看</button>
<button class="btn-small danger" onclick="deleteRecord(${r.id})">🗑</button>
</td>
</tr>`;
}).join('');
renderPagination(data);
}
function renderPagination(data) {
const el = document.getElementById('pagination');
if (!data.records.length) {
el.innerHTML = '';
return;
}
let html = '';
html += `<button class="page-btn" onclick="loadHistory(${data.page - 1})" ${data.page <= 1 ? 'disabled' : ''}> 上一页</button>`;
const total = data.total_pages;
const cur = data.page;
let start = Math.max(1, cur - 2);
let end = Math.min(total, cur + 2);
if (end - start < 4) {
start = Math.max(1, end - 4);
end = Math.min(total, start + 4);
}
if (start > 1) html += `<button class="page-btn" onclick="loadHistory(1)">1</button>`;
if (start > 2) html += `<span style="color:#999;">…</span>`;
for (let p = start; p <= end; p++) {
html += `<button class="page-btn ${p === cur ? 'active' : ''}" onclick="loadHistory(${p})">${p}</button>`;
}
if (end < total - 1) html += `<span style="color:#999;">…</span>`;
if (end < total) html += `<button class="page-btn" onclick="loadHistory(${total})">${total}</button>`;
html += `<button class="page-btn" onclick="loadHistory(${data.page + 1})" ${data.page >= total ? 'disabled' : ''}>下一页 </button>`;
html += `<span class="page-info">第 ${data.page} / ${total} 页</span>`;
el.innerHTML = html;
}
async function viewRecord(id) {
const res = await fetch('/api/history/' + id);
const data = await res.json();
if (!data.success) {
alert('加载失败:' + (data.error || '未知错误'));
return;
}
modalRecord = data.record;
const r = modalRecord;
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>` +
htmlLink;
const body = document.getElementById('modalBody');
if (r.status === 'failed') {
body.innerHTML = `<div class="result-text" style="color:#c62828;">❌ 提取失败:${escapeHtml(r.error || '')}</div>`;
} else if (r.action === 'screenshot') {
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>`;
}
document.getElementById('modalOverlay').classList.add('active');
}
function closeModal() {
document.getElementById('modalOverlay').classList.remove('active');
modalRecord = null;
}
async function deleteRecord(id) {
if (!confirm('确定删除这条记录吗?截图文件将一并删除。')) return;
const res = await fetch('/api/history/' + id, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
// 若当前页删空了则回退一页
if (currentHistory.records.length === 1 && currentPage > 1) {
loadHistory(currentPage - 1);
} else {
loadHistory(currentPage);
}
} else {
alert('删除失败:' + (data.error || '未知错误'));
}
}
function currentModalBlob() {
if (!modalRecord) return null;
if (modalRecord.action === 'screenshot') return null; // 图片用链接下载
const type = modalRecord.action === 'html' ? 'text/html' : 'text/plain;charset=utf-8';
return new Blob([modalRecord.content || ''], { type });
}
function downloadHistoryContent() {
if (!modalRecord) return;
if (modalRecord.action === 'screenshot' || modalRecord.action === 'smart') {
const a = document.createElement('a');
a.href = `/api/history/${modalRecord.id}/file`;
a.download = modalRecord.action === 'smart' ? `smart_${modalRecord.id}.png` : `capture_${modalRecord.id}.png`;
a.click();
return;
}
const blob = currentModalBlob();
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = modalRecord.action === 'html' ? `page_${modalRecord.id}.html` : `page_${modalRecord.id}.txt`;
a.click();
URL.revokeObjectURL(url);
}
function copyHistoryContent() {
if (!modalRecord || !modalRecord.content) return;
navigator.clipboard.writeText(modalRecord.content).then(() => {
alert('已复制到剪贴板');
});
}
</script>
</body>
</html>