Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f1b7c341c | ||
|
|
65259ab5b4 |
@@ -259,6 +259,72 @@ def save_smart_config(cfg):
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 网址缓存 + 失败重试 配置 =====
|
||||||
|
# cache: enabled=是否启用网址缓存(命中直接返回历史, 不重新提取); ttl_seconds=缓存有效期(秒)
|
||||||
|
# retry: max_retries=提取失败后的重试次数(默认2, 即最多尝试3次); delay_seconds=每次重试间隔(秒)
|
||||||
|
DEFAULT_CACHE_CONFIG = {"enabled": True, "ttl_seconds": 3600}
|
||||||
|
DEFAULT_RETRY_CONFIG = {"max_retries": 2, "delay_seconds": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def load_capture_settings():
|
||||||
|
"""读取 网址缓存+失败重试 配置(缺省回默认;存于 config.json 顶层 cache/retry 段)"""
|
||||||
|
settings = {"cache": dict(DEFAULT_CACHE_CONFIG), "retry": dict(DEFAULT_RETRY_CONFIG)}
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
saved = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(saved, dict):
|
||||||
|
for sec in ("cache", "retry"):
|
||||||
|
if isinstance(saved.get(sec), dict):
|
||||||
|
for k in settings[sec]:
|
||||||
|
if k in saved[sec] and saved[sec][k] not in (None, ""):
|
||||||
|
settings[sec][k] = saved[sec][k]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def save_capture_settings(settings):
|
||||||
|
"""保存 网址缓存+失败重试 配置(只动 config.json 的 cache/retry 段,保留 smart 配置)"""
|
||||||
|
merged = load_capture_settings()
|
||||||
|
if isinstance(settings, dict):
|
||||||
|
for sec in ("cache", "retry"):
|
||||||
|
if isinstance(settings.get(sec), dict):
|
||||||
|
for k in merged[sec]:
|
||||||
|
if k in settings[sec] and settings[sec][k] not in (None, ""):
|
||||||
|
merged[sec][k] = settings[sec][k]
|
||||||
|
data = {}
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
data["cache"] = merged["cache"]
|
||||||
|
data["retry"] = merged["retry"]
|
||||||
|
CONFIG_FILE.parent.mkdir(exist_ok=True)
|
||||||
|
CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def find_cache_hit(url, action, ttl_seconds):
|
||||||
|
"""
|
||||||
|
按 url+action 在提取历史中查缓存:取最近一条成功记录,且创建时间在 TTL 内。
|
||||||
|
命中返回 dict(记录),未命中返回 None。
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
cutoff = (datetime.now() - timedelta(seconds=max(1, int(ttl_seconds)))).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, url, title, backend, file_path, content, html_path, created_at FROM captures "
|
||||||
|
"WHERE url=? AND action=? AND status='success' AND created_at >= ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(url, action, cutoff)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def extract_json_from_content(content):
|
def extract_json_from_content(content):
|
||||||
"""从大模型输出中稳健提取 JSON 对象(兼容 ```json 代码块包裹、前后废话)"""
|
"""从大模型输出中稳健提取 JSON 对象(兼容 ```json 代码块包裹、前后废话)"""
|
||||||
if not content:
|
if not content:
|
||||||
@@ -680,8 +746,16 @@ async def capture_with_playwright(
|
|||||||
# 如果 domcontentloaded 超时,尝试 commit
|
# 如果 domcontentloaded 超时,尝试 commit
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until="commit", timeout=30000)
|
await page.goto(url, wait_until="commit", timeout=30000)
|
||||||
except:
|
except Exception as e2:
|
||||||
pass
|
return {"success": False, "error": f"页面加载失败(无法访问 {url}): {str(e2)[:200]}"}
|
||||||
|
|
||||||
|
# 兜底:检查是否进入了错误页(连接拒绝/DNS失败等会停在 chrome-error:// 或 about:blank)
|
||||||
|
try:
|
||||||
|
cur_url = page.url
|
||||||
|
if cur_url.startswith("chrome-error://") or cur_url == "about:blank":
|
||||||
|
return {"success": False, "error": f"页面加载失败(无法访问 {url},浏览器停留在错误页)"}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 固定等待时间(用于验证、加载等过程)
|
# 固定等待时间(用于验证、加载等过程)
|
||||||
if wait_time > 0:
|
if wait_time > 0:
|
||||||
@@ -1222,7 +1296,8 @@ def api_info():
|
|||||||
"playwright": "available" if PLAYWRIGHT_AVAILABLE else "unavailable"
|
"playwright": "available" if PLAYWRIGHT_AVAILABLE else "unavailable"
|
||||||
},
|
},
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"/api/capture": "POST - Capture webpage (screenshot/html/text/smart) + 自动入库历史(记录调用者/方式/原始HTML)",
|
"/api/capture": "POST - Capture webpage (screenshot/html/text/smart) + 自动入库历史(记录调用者/方式/原始HTML); 带 ?refresh=1 绕过缓存; 失败自动重试(次数可配)",
|
||||||
|
"/api/capture/settings": "GET/POST - 网址缓存(enabled/ttl_seconds) + 失败重试(max_retries/delay_seconds) 配置",
|
||||||
"/api/backends": "GET - 可用后端列表与默认顺序(供前端渲染后端选择器)",
|
"/api/backends": "GET - 可用后端列表与默认顺序(供前端渲染后端选择器)",
|
||||||
"/api/smart/config": "GET/POST - 智能截图视觉大模型多接口配置(列表顺序=优先级,失败自动降级)",
|
"/api/smart/config": "GET/POST - 智能截图视觉大模型多接口配置(列表顺序=优先级,失败自动降级)",
|
||||||
"/api/smart/test": "POST - 测试指定视觉接口连通性({endpoint_id} / {endpoint} / 旧版{config})",
|
"/api/smart/test": "POST - 测试指定视觉接口连通性({endpoint_id} / {endpoint} / 旧版{config})",
|
||||||
@@ -1484,6 +1559,21 @@ def smart_test_endpoint():
|
|||||||
return jsonify({"success": False, "error": str(e)}), 400
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/capture/settings', methods=['GET', 'POST'])
|
||||||
|
def capture_settings():
|
||||||
|
"""
|
||||||
|
网址缓存 + 失败重试 配置
|
||||||
|
GET -> {success, settings: {cache:{enabled, ttl_seconds}, retry:{max_retries, delay_seconds}}}
|
||||||
|
POST -> body {settings: {...}},保存后返回合并结果
|
||||||
|
"""
|
||||||
|
if request.method == 'GET':
|
||||||
|
return jsonify({"success": True, "settings": load_capture_settings()})
|
||||||
|
data = request.get_json() or {}
|
||||||
|
settings = data.get("settings") or data
|
||||||
|
saved = save_capture_settings(settings)
|
||||||
|
return jsonify({"success": True, "settings": saved})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/capture', methods=['POST'])
|
@app.route('/api/capture', methods=['POST'])
|
||||||
def capture():
|
def capture():
|
||||||
"""
|
"""
|
||||||
@@ -1493,6 +1583,9 @@ def capture():
|
|||||||
{
|
{
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"action": "screenshot" | "html" | "text" | "smart",
|
"action": "screenshot" | "html" | "text" | "smart",
|
||||||
|
"refresh": false, // 可选:true=强制实时提取(绕过网址缓存)
|
||||||
|
"cache": true, // 可选:false=本次请求禁用缓存
|
||||||
|
"cache_ttl_seconds": 3600, // 可选:本次请求的缓存时效(秒),覆盖全局设置;命中判定也用它
|
||||||
"scroll_times": 0,
|
"scroll_times": 0,
|
||||||
"scroll_delay": 1000,
|
"scroll_delay": 1000,
|
||||||
"full_page": false,
|
"full_page": false,
|
||||||
@@ -1541,46 +1634,90 @@ def capture():
|
|||||||
elif call_method not in ("web", "api"):
|
elif call_method not in ("web", "api"):
|
||||||
call_method = "api"
|
call_method = "api"
|
||||||
|
|
||||||
# 按需截图:滚动截图 + 视觉大模型实时判断
|
# ---- 网址缓存:命中直接返回历史,未命中才实时提取 ----------------
|
||||||
if action == "smart":
|
settings = load_capture_settings()
|
||||||
smart_cfg = load_smart_config()
|
cache_cfg = settings.get("cache", DEFAULT_CACHE_CONFIG)
|
||||||
override = data.get("smart_config") or {}
|
retry_cfg = settings.get("retry", DEFAULT_RETRY_CONFIG)
|
||||||
if isinstance(override, dict):
|
force_refresh = bool(data.get("refresh") or data.get("force"))
|
||||||
for k in DEFAULT_SMART_CONFIG:
|
# per-request 缓存参数:cache_ttl_seconds=本次缓存时效(覆盖全局),cache=false / no_cache=true=本次禁用缓存
|
||||||
if k in override and override[k] not in (None, ""):
|
no_cache = data.get("cache") is False or str(data.get("no_cache")).lower() in ("true", "1", "yes")
|
||||||
smart_cfg[k] = override[k]
|
try:
|
||||||
result = smart_capture(url, smart_cfg, wait_time, viewport, backend)
|
cache_ttl_override = max(1, int(data.get("cache_ttl_seconds"))) if data.get("cache_ttl_seconds") is not None else None
|
||||||
backend_used = result.get("backend", backend)
|
except Exception:
|
||||||
title = result.get("title", "")
|
cache_ttl_override = None
|
||||||
if not result["success"]:
|
|
||||||
save_capture(url, title, "smart", backend_used, "failed", error=result.get("error", ""),
|
if cache_cfg.get("enabled") and not force_refresh and not no_cache:
|
||||||
caller=caller, call_method=call_method)
|
ttl = cache_ttl_override or int(cache_cfg.get("ttl_seconds", 3600))
|
||||||
return jsonify(result), 400
|
hit = find_cache_hit(url, action, ttl)
|
||||||
# 拼接长图已存持久化目录,直接入库;原始HTML按月归档
|
if hit:
|
||||||
steps_log = json.dumps(result.get("steps", []), ensure_ascii=False)
|
hit["from_cache"] = True
|
||||||
html_path = save_raw_html(result.get("html", ""))
|
hit["cached_at"] = hit["created_at"]
|
||||||
rid = save_capture(url, title, "smart", backend_used, "success",
|
hit["cache_ttl_seconds"] = ttl
|
||||||
file_path=result["file_path"], content=steps_log,
|
if action == "screenshot":
|
||||||
html_path=html_path, caller=caller, call_method=call_method)
|
if hit["file_path"] and Path(hit["file_path"]).exists():
|
||||||
result["history_id"] = rid
|
return send_file(hit["file_path"], mimetype='image/png')
|
||||||
result["raw_html_path"] = html_path
|
# 文件已丢则继续实时提取
|
||||||
return jsonify(result)
|
elif action == "smart":
|
||||||
|
if hit["file_path"] and Path(hit["file_path"]).exists():
|
||||||
|
try:
|
||||||
|
steps = json.loads(hit["content"] or "[]")
|
||||||
|
except Exception:
|
||||||
|
steps = []
|
||||||
|
return jsonify({"success": True, "from_cache": True, "url": url, "action": "smart",
|
||||||
|
"title": hit["title"], "backend": hit["backend"],
|
||||||
|
"history_id": hit["id"], "file_path": hit["file_path"],
|
||||||
|
"steps": steps, "total_steps": len(steps),
|
||||||
|
"cached_at": hit["created_at"], "cache_ttl_seconds": ttl,
|
||||||
|
"raw_html_path": hit["html_path"]})
|
||||||
|
else:
|
||||||
|
return jsonify({"success": True, "from_cache": True, "url": url, "action": action,
|
||||||
|
"title": hit["title"], "backend": hit["backend"],
|
||||||
|
"history_id": hit["id"],
|
||||||
|
"content": hit["content"],
|
||||||
|
"html": hit["content"] if action == "html" else None,
|
||||||
|
"text": hit["content"] if action == "text" else None,
|
||||||
|
"cached_at": hit["created_at"], "cache_ttl_seconds": ttl,
|
||||||
|
"raw_html_path": hit["html_path"]})
|
||||||
|
|
||||||
|
# ---- 实时提取(带失败重试:次数可配,默认重试 2 次) ----------------
|
||||||
|
max_retries = max(0, int(retry_cfg.get("max_retries", 2)))
|
||||||
|
retry_delay = max(0, int(retry_cfg.get("delay_seconds", 2)))
|
||||||
|
attempt = 0
|
||||||
|
result = None
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
# 按需截图:滚动截图 + 视觉大模型实时判断
|
||||||
|
if action == "smart":
|
||||||
|
smart_cfg = load_smart_config()
|
||||||
|
override = data.get("smart_config") or {}
|
||||||
|
if isinstance(override, dict):
|
||||||
|
for k in DEFAULT_SMART_CONFIG:
|
||||||
|
if k in override and override[k] not in (None, ""):
|
||||||
|
smart_cfg[k] = override[k]
|
||||||
|
result = smart_capture(url, smart_cfg, wait_time, viewport, backend)
|
||||||
|
else:
|
||||||
|
result = capture_webpage(
|
||||||
|
url=url,
|
||||||
|
action=action,
|
||||||
|
scroll_times=scroll_times,
|
||||||
|
scroll_delay=scroll_delay,
|
||||||
|
full_page=full_page,
|
||||||
|
viewport=viewport,
|
||||||
|
wait_time=wait_time,
|
||||||
|
backend=backend,
|
||||||
|
cdp_port=cdp_port
|
||||||
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
break
|
||||||
|
if attempt <= max_retries:
|
||||||
|
time.sleep(retry_delay) # 重试前短暂等待
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
result["attempts"] = attempt
|
||||||
|
|
||||||
result = capture_webpage(
|
|
||||||
url=url,
|
|
||||||
action=action,
|
|
||||||
scroll_times=scroll_times,
|
|
||||||
scroll_delay=scroll_delay,
|
|
||||||
full_page=full_page,
|
|
||||||
viewport=viewport,
|
|
||||||
wait_time=wait_time,
|
|
||||||
backend=backend,
|
|
||||||
cdp_port=cdp_port
|
|
||||||
)
|
|
||||||
|
|
||||||
backend_used = result.get("backend", backend)
|
backend_used = result.get("backend", backend)
|
||||||
title = result.get("title", "")
|
title = result.get("title", "")
|
||||||
|
|
||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
save_capture(url, title, action, backend_used, "failed", error=result.get("error", ""),
|
save_capture(url, title, action, backend_used, "failed", error=result.get("error", ""),
|
||||||
caller=caller, call_method=call_method)
|
caller=caller, call_method=call_method)
|
||||||
@@ -1588,18 +1725,27 @@ def capture():
|
|||||||
|
|
||||||
# 每次提取后都把最原始 HTML 按月归档到本地
|
# 每次提取后都把最原始 HTML 按月归档到本地
|
||||||
html_path = save_raw_html(result.get("html", ""))
|
html_path = save_raw_html(result.get("html", ""))
|
||||||
|
|
||||||
|
if action == "smart":
|
||||||
|
steps_log = json.dumps(result.get("steps", []), ensure_ascii=False)
|
||||||
|
rid = save_capture(url, title, "smart", backend_used, "success",
|
||||||
|
file_path=result["file_path"], content=steps_log,
|
||||||
|
html_path=html_path, caller=caller, call_method=call_method)
|
||||||
|
result["history_id"] = rid
|
||||||
|
result["raw_html_path"] = html_path
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
if action == "screenshot":
|
if action == "screenshot":
|
||||||
persisted = persist_screenshot(result["file_path"])
|
persisted = persist_screenshot(result["file_path"])
|
||||||
save_capture(url, title, action, backend_used, "success", file_path=persisted,
|
save_capture(url, title, action, backend_used, "success", file_path=persisted,
|
||||||
html_path=html_path, caller=caller, call_method=call_method)
|
html_path=html_path, caller=caller, call_method=call_method)
|
||||||
return send_file(persisted, mimetype='image/png')
|
return send_file(persisted, mimetype='image/png')
|
||||||
|
|
||||||
elif action == "html":
|
elif action == "html":
|
||||||
save_capture(url, title, action, backend_used, "success", content=result["html"],
|
save_capture(url, title, action, backend_used, "success", content=result["html"],
|
||||||
html_path=html_path, caller=caller, call_method=call_method)
|
html_path=html_path, caller=caller, call_method=call_method)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
elif action == "text":
|
elif action == "text":
|
||||||
save_capture(url, title, action, backend_used, "success", content=result["text"],
|
save_capture(url, title, action, backend_used, "success", content=result["text"],
|
||||||
html_path=html_path, caller=caller, call_method=call_method)
|
html_path=html_path, caller=caller, call_method=call_method)
|
||||||
|
|||||||
+153
-2
@@ -608,6 +608,7 @@
|
|||||||
🧠 按需截图(AI判断滚动)
|
🧠 按需截图(AI判断滚动)
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="btn-small" onclick="openSmartConfig()" style="margin-top:0;" title="配置视觉大模型接口">⚙️ 智能配置</button>
|
<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>
|
</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;">
|
<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;">
|
||||||
@@ -621,7 +622,16 @@
|
|||||||
<input type="checkbox" id="fullPage">
|
<input type="checkbox" id="fullPage">
|
||||||
全页截图
|
全页截图
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="forceRefresh">
|
||||||
|
🔄 强制刷新(绕过网址缓存,重新提取)
|
||||||
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -738,6 +748,8 @@
|
|||||||
<p><strong>重要提示:</strong></p>
|
<p><strong>重要提示:</strong></p>
|
||||||
<ul style="margin: 10px 0; line-height: 1.8;">
|
<ul style="margin: 10px 0; line-height: 1.8;">
|
||||||
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图;支持<strong>多视觉大模型接口配置</strong>(「⚙️ 智能配置」中增删改,列表顺序=优先级,失败/报错自动降级到下一个接口)</li>
|
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图;支持<strong>多视觉大模型接口配置</strong>(「⚙️ 智能配置」中增删改,列表顺序=优先级,失败/报错自动降级到下一个接口)</li>
|
||||||
|
<li>📦 <strong>网址缓存</strong>:默认开启——同一网址先查提取历史(同动作、成功、在有效期内)直接返回,不重复提取;未命中才实时提取。可「⚙️ 缓存/重试」关开关/改有效期,或请求带 <code>refresh:true</code> 强制绕过</li>
|
||||||
|
<li>🔁 <strong>失败自动重试</strong>:提取失败(网络/超时/浏览器错误)自动重试,次数可设(默认 2 次),响应带 <code>attempts</code> 字段</li>
|
||||||
<li>📊 <strong>提取历史统计</strong>:历史区点「📊 统计」,可按天/操作类型/后端/状态/调用者/调用方式等角度切换图表(柱状/饼图可换,支持 7/30/90 天范围,可下载 PNG)</li>
|
<li>📊 <strong>提取历史统计</strong>:历史区点「📊 统计」,可按天/操作类型/后端/状态/调用者/调用方式等角度切换图表(柱状/饼图可换,支持 7/30/90 天范围,可下载 PNG)</li>
|
||||||
<li>📝 <strong>提取文本</strong>:自动剔除脚本/样式/标签,得到干净的前端可读文本,适合直接用于分析/喂给大模型</li>
|
<li>📝 <strong>提取文本</strong>:自动剔除脚本/样式/标签,得到干净的前端可读文本,适合直接用于分析/喂给大模型</li>
|
||||||
<li>⏱️ <strong>页面加载等待</strong>:某些网站需要验证过程(如 Cloudflare),请设置较长时间(10000-30000ms)</li>
|
<li>⏱️ <strong>页面加载等待</strong>:某些网站需要验证过程(如 Cloudflare),请设置较长时间(10000-30000ms)</li>
|
||||||
@@ -759,6 +771,10 @@
|
|||||||
"caller": "news-tracker", // 可选:调用者标识(历史记录里显示,默认游客)
|
"caller": "news-tracker", // 可选:调用者标识(历史记录里显示,默认游客)
|
||||||
"call_method": "api", // 可选:调用方式 web/api(缺省自动判定)
|
"call_method": "api", // 可选:调用方式 web/api(缺省自动判定)
|
||||||
"viewport": {"width":1280,"height":700},
|
"viewport": {"width":1280,"height":700},
|
||||||
|
// ---- 📦 网址缓存(默认开启,全局配置在 /api/capture/settings)----
|
||||||
|
"refresh": false, // 可选:true=强制实时提取(绕过缓存)
|
||||||
|
"cache": true, // 可选:false=本次请求禁用缓存
|
||||||
|
"cache_ttl_seconds": 3600, // 可选:本次缓存时效(秒),覆盖全局有效期,命中判定也用它
|
||||||
"smart_config": { // 可选:临时覆盖按需截图配置(接口列表按顺序=优先级,失败自动降级)
|
"smart_config": { // 可选:临时覆盖按需截图配置(接口列表按顺序=优先级,失败自动降级)
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
{"name":"本地Qwen", "base_url":"http://121.40.164.32:18008/v1", "api_key":"xxxx", "model":"unsloth/Qwen3.8-27B-NVFP4", "enabled":true},
|
{"name":"本地Qwen", "base_url":"http://121.40.164.32:18008/v1", "api_key":"xxxx", "model":"unsloth/Qwen3.8-27B-NVFP4", "enabled":true},
|
||||||
@@ -768,6 +784,18 @@
|
|||||||
"scroll_ratio": 0.85
|
"scroll_ratio": 0.85
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 缓存命中响应(text/html/smart 为 JSON;screenshot 直接返回历史图片文件):
|
||||||
|
// {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": 3600}, // enabled=缓存开关, ttl_seconds=有效期(秒)
|
||||||
|
"retry": {"max_retries": 2, "delay_seconds": 2} // max_retries=失败重试次数(默认2), delay_seconds=重试间隔(秒)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GET /api/smart/config // 获取智能截图配置(含 endpoints 多接口列表)
|
GET /api/smart/config // 获取智能截图配置(含 endpoints 多接口列表)
|
||||||
POST /api/smart/config // 保存配置(body: {config:{endpoints:[...], prompt, max_scrolls,...}})
|
POST /api/smart/config // 保存配置(body: {config:{endpoints:[...], prompt, max_scrolls,...}})
|
||||||
@@ -825,6 +853,57 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
|||||||
</div>
|
</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;">缓存有效期(分钟)</label>
|
||||||
|
<input type="number" id="cacheTtl" min="1" max="10080" value="60" style="width:100%;padding:10px;border:1.5px solid #e0e0e0;border-radius:6px;">
|
||||||
|
<small style="color:#999;display:block;margin-top:4px;">超期后再次提取自动刷新缓存。默认 60 分钟。</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;">重试间隔(秒)</label>
|
||||||
|
<input type="number" id="retryDelay" min="0" max="60" value="2" 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-overlay" id="statsOverlay" onclick="if(event.target===this)closeStats()">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -884,14 +963,79 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
|||||||
let modalRecord = null;
|
let modalRecord = null;
|
||||||
let smartConfig = null;
|
let smartConfig = null;
|
||||||
let lastSmartHistoryId = null;
|
let lastSmartHistoryId = null;
|
||||||
|
let captureSettings = { cache: { enabled: true, ttl_seconds: 3600 }, retry: { max_retries: 2, delay_seconds: 2 } };
|
||||||
|
|
||||||
// 页面加载时读取历史 + 智能配置 + 可用后端
|
// 页面加载时读取历史 + 智能配置 + 可用后端 + 缓存/重试设置
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
loadHistory(1);
|
loadHistory(1);
|
||||||
loadSmartConfig();
|
loadSmartConfig();
|
||||||
loadBackends();
|
loadBackends();
|
||||||
|
loadCaptureSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function updateCacheBadge() {
|
||||||
|
const en = captureSettings.cache && captureSettings.cache.enabled;
|
||||||
|
document.getElementById('cacheStateBadge').textContent = en ? '✅ 开启' : '⛔ 关闭';
|
||||||
|
document.getElementById('cacheStateBadge').style.color = en ? '#2e7d32' : '#c62828';
|
||||||
|
const min = Math.round((captureSettings.cache && captureSettings.cache.ttl_seconds || 3600) / 60);
|
||||||
|
document.getElementById('cacheTtlBadge').textContent = min + ' 分钟';
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
document.getElementById('cacheEnabled').checked = !!(captureSettings.cache && captureSettings.cache.enabled);
|
||||||
|
document.getElementById('cacheTtl').value = Math.round(((captureSettings.cache && captureSettings.cache.ttl_seconds) || 3600) / 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 : 2;
|
||||||
|
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 settings = {
|
||||||
|
cache: {
|
||||||
|
enabled: document.getElementById('cacheEnabled').checked,
|
||||||
|
ttl_seconds: (parseInt(document.getElementById('cacheTtl').value) || 60) * 60
|
||||||
|
},
|
||||||
|
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 ? '开启' : '关闭'}(有效期 ${Math.round(data.settings.cache.ttl_seconds / 60)} 分钟)| 失败重试 ${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() {
|
async function loadBackends() {
|
||||||
const sel = document.getElementById('backend');
|
const sel = document.getElementById('backend');
|
||||||
const hint = document.getElementById('backendHint');
|
const hint = document.getElementById('backendHint');
|
||||||
@@ -1313,6 +1457,7 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
|||||||
currentData = {
|
currentData = {
|
||||||
url,
|
url,
|
||||||
action,
|
action,
|
||||||
|
refresh: document.getElementById('forceRefresh').checked,
|
||||||
scroll_times: scrollTimes,
|
scroll_times: scrollTimes,
|
||||||
scroll_delay: scrollDelay,
|
scroll_delay: scrollDelay,
|
||||||
full_page: fullPage,
|
full_page: fullPage,
|
||||||
@@ -1353,9 +1498,13 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
|||||||
`<img src="${imageUrl}" class="result-image" alt="截图结果">`;
|
`<img src="${imageUrl}" class="result-image" alt="截图结果">`;
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
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') {
|
if (action === 'html') {
|
||||||
currentBlob = new Blob([data.html], { type: 'text/html' });
|
currentBlob = new Blob([data.html], { type: 'text/html' });
|
||||||
document.getElementById('resultContent').innerHTML =
|
document.getElementById('resultContent').innerHTML =
|
||||||
|
cacheTag +
|
||||||
`<div class="result-code"><pre>${escapeHtml(data.html)}</pre></div>`;
|
`<div class="result-code"><pre>${escapeHtml(data.html)}</pre></div>`;
|
||||||
} else if (action === 'smart') {
|
} else if (action === 'smart') {
|
||||||
lastSmartHistoryId = data.history_id || null;
|
lastSmartHistoryId = data.history_id || null;
|
||||||
@@ -1364,6 +1513,7 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
|||||||
`<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>`
|
`<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('');
|
).join('');
|
||||||
document.getElementById('resultContent').innerHTML =
|
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>` +
|
`<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'">` +
|
`<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>` +
|
`<details style="margin-top:12px;"><summary style="cursor:pointer;color:#667eea;font-weight:600;">📋 查看 AI 判断过程(${(data.steps || []).length} 步)</summary>` +
|
||||||
@@ -1372,7 +1522,8 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
|||||||
currentBlob = new Blob([data.text], { type: 'text/plain;charset=utf-8' });
|
currentBlob = new Blob([data.text], { type: 'text/plain;charset=utf-8' });
|
||||||
const lines = (data.text || '').split('\n').length;
|
const lines = (data.text || '').split('\n').length;
|
||||||
document.getElementById('resultContent').innerHTML =
|
document.getElementById('resultContent').innerHTML =
|
||||||
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} | 共 ${lines} 行,${(data.text || '').length} 字符(已剔除标签与无效字符)</div>` +
|
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>`;
|
`<div class="result-text">${escapeHtml(data.text || '')}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user