@@ -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 : 20 px ;
flex-wrap : wrap ;
}
. radio-label {
@@ -201,16 +202,62 @@
word-wrap : break-word ;
}
. result-text {
background : #f8f9fa ;
border : 1 px solid #e0e0e0 ;
color : #222 ;
padding : 20 px ;
border-radius : 8 px ;
overflow : auto ;
max-height : 600 px ;
font-family : - apple-system , BlinkMacSystemFont , 'Segoe UI' , Roboto , sans-serif ;
font-size : 14 px ;
line-height : 1.8 ;
white-space : pre-wrap ;
word-wrap : break-word ;
}
. actions {
margin-top : 20 px ;
display : flex ;
gap : 10 px ;
flex-wrap : wrap ;
}
. btn-secondary {
background : #6c757d ;
padding : 10 px 20 px ;
font-size : 14 px ;
width : auto ;
margin-top : 0 ;
}
. btn-small {
background : #667eea ;
color : white ;
border : none ;
padding : 6 px 14 px ;
font-size : 13 px ;
border-radius : 6 px ;
cursor : pointer ;
transition : background 0.2 s ;
}
. 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,266 @@
display : block ;
}
/* ===== 历史记录区域 ===== */
. history-header {
display : flex ;
align-items : center ;
justify-content : space-between ;
flex-wrap : wrap ;
gap : 15 px ;
margin-bottom : 20 px ;
}
. history-header h3 {
font-size : 1.4 em ;
color : #333 ;
}
. history-toolbar {
display : flex ;
gap : 10 px ;
flex-wrap : wrap ;
align-items : center ;
}
. history-toolbar select ,
. history-toolbar input [ type = "text" ] {
width : auto ;
min-width : 120 px ;
padding : 8 px 12 px ;
font-size : 14 px ;
}
. history-toolbar . search-input {
min-width : 220 px ;
}
. history-count {
color : #666 ;
font-size : 14 px ;
}
. history-table {
width : 100 % ;
border-collapse : collapse ;
}
. history-table th ,
. history-table td {
padding : 12 px 10 px ;
text-align : left ;
border-bottom : 1 px solid #eee ;
vertical-align : middle ;
}
. history-table th {
font-size : 13 px ;
color : #666 ;
font-weight : 600 ;
white-space : nowrap ;
}
. history-table td {
font-size : 14 px ;
}
. history-table tr : hover td {
background : #f8f9ff ;
}
. badge {
display : inline-block ;
padding : 3 px 10 px ;
border-radius : 20 px ;
font-size : 12 px ;
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 : 280 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
color : #1565c0 ;
text-decoration : none ;
display : inline-block ;
vertical-align : bottom ;
}
. history-title {
max-width : 220 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
color : #333 ;
display : inline-block ;
vertical-align : bottom ;
}
. history-empty {
text-align : center ;
padding : 40 px ;
color : #999 ;
font-size : 15 px ;
}
. pagination {
display : flex ;
align-items : center ;
justify-content : center ;
gap : 8 px ;
margin-top : 20 px ;
flex-wrap : wrap ;
}
. page-btn {
min-width : 36 px ;
padding : 7 px 12 px ;
border : 1 px solid #ddd ;
background : white ;
border-radius : 6 px ;
cursor : pointer ;
font-size : 14 px ;
color : #333 ;
transition : all 0.2 s ;
}
. 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 : 14 px ;
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 : 30 px ;
overflow-y : auto ;
}
. modal-overlay . active {
display : flex ;
}
. modal {
background : white ;
border-radius : 14 px ;
width : 100 % ;
max-width : 900 px ;
margin : auto ;
box-shadow : 0 20 px 60 px rgba ( 0 , 0 , 0 , 0.4 ) ;
overflow : hidden ;
}
. modal-header {
display : flex ;
align-items : center ;
justify-content : space-between ;
padding : 18 px 24 px ;
background : #f8f9fa ;
border-bottom : 1 px solid #eee ;
gap : 15 px ;
flex-wrap : wrap ;
}
. modal-header h3 {
font-size : 16 px ;
color : #333 ;
word-break : break-all ;
}
. modal-meta {
padding : 10 px 24 px ;
background : #f8f9fa ;
border-bottom : 1 px solid #eee ;
font-size : 13 px ;
color : #666 ;
display : flex ;
gap : 20 px ;
flex-wrap : wrap ;
}
. modal-body {
padding : 20 px 24 px ;
max-height : 70 vh ;
overflow : auto ;
}
. modal-body . result-image {
width : 100 % ;
}
. modal-close {
background : #6c757d ;
color : white ;
border : none ;
width : 32 px ;
height : 32 px ;
border-radius : 50 % ;
font-size : 18 px ;
cursor : pointer ;
line-height : 1 ;
flex-shrink : 0 ;
}
. modal-close : hover {
background : #545b62 ;
}
. api-docs {
margin-top : 30 px ;
padding : 20 px ;
@@ -254,13 +561,24 @@
overflow-x : auto ;
margin-top : 10 px ;
}
@ 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 > 网页截图与代码 提取服务< / p >
< p > 网页截图 / HTML / 可读文本 提取服务< / p >
< / div >
< div class = "card" >
@@ -281,8 +599,20 @@
< 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 >
< / 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 > :每次截图后由视觉大模型实时判断——是否已截全主题内容、还需不需要继续往下滚动,自动滚动直到大模型判定完成,最后拼成一张长图。大模型接口可在「⚙️ 智能配置」中随时修改(默认已配置 qwen3.6-plus)。
< / div >
< div class = "form-group" >
< label > 高级选项< / label >
@@ -295,20 +625,21 @@
< / div >
< div class = "form-group" >
< label > 滚动 设置(用于加载动态内容 ) < / label >
< label > 等待 设置(重要! ) < / label >
< div class = "row" >
< div >
< label for = "scroll Times " > 滚动次数 < / label >
< label for = "wait Time" > 页面加载等待(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 >
< label for = "waitTime" > 页面加载等待(ms)< / label >
< input type = "number" id = "waitTime" value = "2000" min = "0" max = "10000" step = "500" >
< / div >
< / div >
< / div >
@@ -349,18 +680,153 @@
< / 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 >
< / 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 > 📚 API 文档 < / h3 >
< p > POST /api/capture < / p >
< pre > < code > {
< h3 > 📚 使用说明 < / h3 >
< p > < strong > 重要提示: < / strong > < / p >
< ul style = "margin: 10px 0; line-height: 1.8;" >
< li > 🧠 < strong > 按需截图< / strong > :每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图(大模型接口可在「⚙️ 智能配置」修改)< / 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/< 月份> /< / 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": "screenshot", // 或 "html "
"scroll _times ": 3,
"scroll_delay": 1000,
"full_page": true,
"viewport": {"width": 1920, "height": 1080},
"wait_time": 2000
}< / code > < / pre >
"action": "smart", // "screenshot" | "html" | "text" | "smart "
"wait _time": 15000, // 重要!验证网站需设置较长等待
"scroll_times": 3, // 可选:加载动态内容
"scroll_delay": 1000, // 可选:滚动间隔
"full_page": true, // 可选:全页截图
"backend": "playwright", // 可选:playwright / agent-browser
"caller": "news-tracker", // 可选:调用者标识(历史记录里显示,默认游客)
"call_method": "api", // 可选:调用方式 web/api(缺省自动判定)
"viewport": {"width":1280,"height":700},
"smart_config": { // 可选:临时覆盖按需截图 LLM 配置
"base_url": "https://www.autodl.art/api/v1",
"api_key": "sk-xxx",
"model": "qwen3.6-plus",
"max_scrolls": 20,
"scroll_ratio": 0.85
}
}
GET /api/smart/config // 获取按需截图 LLM 配置
POST /api/smart/config // 保存配置(body: {config:{...}})
POST /api/smart/test // 测试连接(body: {config:{...}} 可选)
GET /api/history?page=1& page_size=15& action=all& search=关键词 // 历史分页
GET /api/history/< id> // 历史详情
GET /api/history/< id> /file // 历史截图文件
DELETE /api/history/< id> // 删除历史< / 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 class = "form-group" >
< label for = "scBaseUrl" > 接口地址 base_url< / label >
< input type = "text" id = "scBaseUrl" placeholder = "https://.../api/v1" >
< / div >
< div class = "form-group" >
< label for = "scApiKey" > API Key< / label >
< input type = "password" id = "scApiKey" placeholder = "sk-..." >
< / div >
< div class = "form-group" >
< label for = "scModel" > 模型名称< / label >
< input type = "text" id = "scModel" placeholder = "qwen3.6-plus" >
< / div >
< div class = "form-group" >
< label for = "scPrompt" > 判断提示词(告诉大模型如何判断是否截全)< / label >
< textarea id = "scPrompt" rows = "7" 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 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 = "testSmartConfig()" > 🧪 测试连接< / button >
< button class = "btn btn-secondary" onclick = "saveSmartConfig()" > 💾 保存配置< / button >
< button class = "btn btn-secondary" onclick = "closeSmartConfig()" > 关闭< / 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 >
@@ -372,6 +838,105 @@
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 ;
// 页面加载时读取历史 + 智能配置
window . addEventListener ( 'load' , ( ) => {
loadHistory ( 1 ) ;
loadSmartConfig ( ) ;
} ) ;
// 操作类型切换:智能模式显示提示条
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' ;
} ) ;
} ) ;
/* ===== 智能截图配置 ===== */
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 openSmartConfig ( ) {
if ( ! smartConfig ) return ;
document . getElementById ( 'scBaseUrl' ) . value = smartConfig . base _url || '' ;
document . getElementById ( 'scApiKey' ) . value = smartConfig . api _key || '' ;
document . getElementById ( 'scModel' ) . value = smartConfig . model || '' ;
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 = '' ;
document . getElementById ( 'smartCfgOverlay' ) . classList . add ( 'active' ) ;
}
function closeSmartConfig ( ) {
document . getElementById ( 'smartCfgOverlay' ) . classList . remove ( 'active' ) ;
}
function collectSmartConfigForm ( ) {
return {
base _url : document . getElementById ( 'scBaseUrl' ) . value . trim ( ) ,
api _key : document . getElementById ( 'scApiKey' ) . value . trim ( ) ,
model : document . getElementById ( 'scModel' ) . value . trim ( ) ,
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 testSmartConfig ( ) {
const cfg = collectSmartConfigForm ( ) ;
const el = document . getElementById ( 'scTestResult' ) ;
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 ( { config : cfg } )
} ) ;
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> ` ;
}
}
async function saveSmartConfig ( ) {
const cfg = collectSmartConfigForm ( ) ;
const el = document . getElementById ( 'scTestResult' ) ;
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 ;
el . innerHTML = '<span style="color:#2e7d32;">✅ 配置已保存并生效</span>' ;
} else {
el . innerHTML = ` <span style="color:#c62828;">❌ 保存失败: ${ escapeHtml ( data . error || '未知错误' ) } </span> ` ;
}
} catch ( err ) {
el . innerHTML = ` <span style="color:#c62828;">❌ 保存失败: ${ escapeHtml ( err . message ) } </span> ` ;
}
}
form . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ( ) ;
@@ -395,7 +960,8 @@
viewport : {
width : viewportWidth ,
height : viewportHeight
}
} ,
smart _config : action === 'smart' ? ( smartConfig || { } ) : undefined
} ;
// 显示加载
@@ -408,7 +974,8 @@
const response = await fetch ( '/api/capture' , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json'
'Content-Type' : 'application/json' ,
'X-From' : 'web'
} ,
body : JSON . stringify ( currentData )
} ) ;
@@ -421,16 +988,37 @@
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 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 || '' ) } </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 ;
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' ) ;
@@ -447,12 +1035,24 @@
}
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 ;
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 ) ;
}
@@ -474,6 +1074,219 @@
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='<p style="color:#c62828;">截图文件不存在或已删除</p>'"> ` ;
} 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 || '' ) } </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 >