新增可读文本提取 + 历史记录分页管理

- 新增 text 动作:提取页面可读文本(innerText + 空行/行尾清理,剔除脚本样式标签),agent-browser 与 playwright 双后端支持,自动提取页面标题
- 提取历史持久化(SQLite WAL):每次捕获自动入库,截图文件存持久化目录
- 新增 API:GET /api/history(分页/类型筛选/搜索)、GET /api/history/<id>(详情)、GET /api/history/<id>/file(截图)、DELETE /api/history/<id>
- 前端:新增提取文本选项+干净文本展示;新增提取历史卡片(分页/筛选/搜索/查看弹窗/删除/下载/复制)
This commit is contained in:
2026-08-29 17:37:43 +08:00
parent f7f14d7114
commit 2a9f187ce8
3 changed files with 837 additions and 30 deletions
+591 -14
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Capture - 网页截图与代码提取</title>
<title>Web Capture - 网页截图/HTML/文本提取</title>
<style>
* {
margin: 0;
@@ -77,6 +77,7 @@
.radio-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.radio-label {
@@ -201,16 +202,62 @@
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 {
@@ -227,6 +274,256 @@
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;
}
.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;
@@ -254,13 +551,22 @@
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) {
display: none;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌐 Web Capture</h1>
<p>网页截图与代码提取服务</p>
<p>网页截图 / HTML / 可读文本 提取服务</p>
</div>
<div class="card">
@@ -281,6 +587,10 @@
<input type="radio" name="action" value="html">
📄 提取HTML
</label>
<label class="radio-label">
<input type="radio" name="action" value="text">
📝 提取文本
</label>
</div>
</div>
@@ -350,25 +660,87 @@
</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>
</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>
</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 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>:自动剔除脚本/样式/标签,得到干净的前端可读文本,适合直接用于分析/喂给大模型</li>
<li>⏱️ <strong>页面加载等待</strong>:某些网站需要验证过程(如 Cloudflare),请设置较长时间(10000-30000ms</li>
<li>🔄 <strong>滚动次数</strong>:用于加载动态内容(如微博、推特等),建议 3-5 次</li>
<li>🎯 <strong>全页截图</strong>:滚动加载后建议开启此选项</li>
<li>📜 <strong>提取历史</strong>:每次提取自动入库,支持分页浏览、按类型筛选、搜索与删除</li>
</ul>
<p style="margin-top: 15px;"><strong>API 调用:</strong></p>
<pre><code>POST /api/capture
{
"url": "https://example.com",
"action": "screenshot", // 或 "html"
"wait_time": 15000, // 重要!验证网站需设置较长等待
"scroll_times": 3, // 可选:加载动态内容
"scroll_delay": 1000, // 可选:滚动间隔
"full_page": true, // 可选:全页截图
"backend": "playwright" // 可选:playwright agent-browser
}</code></pre>
"action": "text", // "screenshot" | "html" | "text"
"wait_time": 15000, // 重要!验证网站需设置较长等待
"scroll_times": 3, // 可选:加载动态内容
"scroll_delay": 1000, // 可选:滚动间隔
"full_page": true, // 可选:全页截图
"backend": "playwright" // 可选:playwright / agent-browser
}
GET /api/history?page=1&page_size=15&action=all&search=关键词 // 历史分页
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="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>
@@ -380,6 +752,12 @@
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;
// 页面加载时读取历史
window.addEventListener('load', () => loadHistory(1));
form.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -429,16 +807,26 @@
if (action === 'screenshot') {
currentBlob = await response.blob();
const imageUrl = URL.createObjectURL(currentBlob);
document.getElementById('resultContent').innerHTML =
document.getElementById('resultContent').innerHTML =
`<img src="${imageUrl}" class="result-image" alt="截图结果">`;
} else {
const data = await response.json();
currentBlob = new Blob([data.html], { type: 'text/html' });
document.getElementById('resultContent').innerHTML =
`<div class="result-code"><pre>${escapeHtml(data.html)}</pre></div>`;
if (action === 'html') {
currentBlob = new Blob([data.html], { type: 'text/html' });
document.getElementById('resultContent').innerHTML =
`<div class="result-code"><pre>${escapeHtml(data.html)}</pre></div>`;
} else {
currentBlob = new Blob([data.text], { type: 'text/plain;charset=utf-8' });
const lines = (data.text || '').split('\n').length;
document.getElementById('resultContent').innerHTML =
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} ${lines} 行,${(data.text || '').length} 字符(已剔除标签与无效字符)</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');
@@ -460,7 +848,9 @@
const url = URL.createObjectURL(currentBlob);
const a = document.createElement('a');
a.href = url;
a.download = currentData.action === 'screenshot' ? 'screenshot.png' : 'page.html';
const name = currentData.action === 'screenshot' ? 'screenshot.png'
: currentData.action === 'html' ? 'page.html' : 'page.txt';
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
@@ -483,6 +873,193 @@
currentData = null;
currentBlob = 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: '📝 文本'
};
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>';
return `<tr>
<td style="white-space:nowrap;">${escapeHtml(r.created_at)}</td>
<td><span class="badge ${escapeHtml(badgeClass)}">${badgeText}</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);
document.getElementById('modalMeta').innerHTML =
`<span>类型:<span class="badge ${badgeClass}">${badgeText}</span></span>` +
`<span>时间:${escapeHtml(r.created_at)}</span>` +
`<span>后端:${escapeHtml(r.backend || '—')}</span>` +
`<span>状态:${r.status === 'failed' ? '<span style="color:#c62828;">失败</span>' : '<span style="color:#2e7d32;">成功</span>'}</span>`;
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 {
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') {
const a = document.createElement('a');
a.href = `/api/history/${modalRecord.id}/file`;
a.download = `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>