v1.9.0 网址缓存查询(开关+时效TTL+强制刷新) + 提取失败自动重试(次数可配默认2)
1) 网址缓存:
- /api/capture 请求前先查提取历史: 同 url+action 成功记录且在 TTL 内直接返回(截图返回文件/文本内容返回数据), 不重新提取
- 缓存开关 enabled + 有效期 ttl_seconds(默认3600s=1小时) 可配; 请求带 refresh=true 强制绕过缓存
- 命中响应带 from_cache/cached_at/history_id/cache_ttl_seconds 标记
- GET/POST /api/capture/settings 配置接口; 前端⚙️缓存/重试设置弹窗(开关/有效期/重试次数/间隔) + 高级选项区缓存状态徽章 + 强制刷新勾选框 + 结果区缓存命中提示条
2) 失败重试:
- 提取失败(网络/超时/浏览器错误)自动重试, max_retries 默认2(最多尝试3次), delay_seconds 间隔默认2s
- 响应带 attempts=总尝试次数; 仅最终失败才入库 failed 记录
3) 修复: playwright goto 失败异常被吞(连接拒绝的地址返回空内容成功) -> 两次 goto 失败直接返回失败 + 错误页(chrome-error://about:blank)兜底判断, 保证真实失败能触发重试
验证: text/screenshot 缓存命中(cmp 同图)、refresh 绕过、重试 attempts=2/3、设置保存恢复
This commit is contained in:
@@ -259,6 +259,72 @@ def save_smart_config(cfg):
|
||||
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):
|
||||
"""从大模型输出中稳健提取 JSON 对象(兼容 ```json 代码块包裹、前后废话)"""
|
||||
if not content:
|
||||
@@ -680,8 +746,16 @@ async def capture_with_playwright(
|
||||
# 如果 domcontentloaded 超时,尝试 commit
|
||||
try:
|
||||
await page.goto(url, wait_until="commit", timeout=30000)
|
||||
except:
|
||||
pass
|
||||
except Exception as e2:
|
||||
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:
|
||||
@@ -1222,7 +1296,8 @@ def api_info():
|
||||
"playwright": "available" if PLAYWRIGHT_AVAILABLE else "unavailable"
|
||||
},
|
||||
"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/smart/config": "GET/POST - 智能截图视觉大模型多接口配置(列表顺序=优先级,失败自动降级)",
|
||||
"/api/smart/test": "POST - 测试指定视觉接口连通性({endpoint_id} / {endpoint} / 旧版{config})",
|
||||
@@ -1484,6 +1559,21 @@ def smart_test_endpoint():
|
||||
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'])
|
||||
def capture():
|
||||
"""
|
||||
@@ -1493,6 +1583,7 @@ def capture():
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"action": "screenshot" | "html" | "text" | "smart",
|
||||
"refresh": false, // 可选:true=强制实时提取(绕过网址缓存)
|
||||
"scroll_times": 0,
|
||||
"scroll_delay": 1000,
|
||||
"full_page": false,
|
||||
@@ -1541,46 +1632,84 @@ def capture():
|
||||
elif call_method not in ("web", "api"):
|
||||
call_method = "api"
|
||||
|
||||
# 按需截图:滚动截图 + 视觉大模型实时判断
|
||||
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)
|
||||
backend_used = result.get("backend", backend)
|
||||
title = result.get("title", "")
|
||||
if not result["success"]:
|
||||
save_capture(url, title, "smart", backend_used, "failed", error=result.get("error", ""),
|
||||
caller=caller, call_method=call_method)
|
||||
return jsonify(result), 400
|
||||
# 拼接长图已存持久化目录,直接入库;原始HTML按月归档
|
||||
steps_log = json.dumps(result.get("steps", []), ensure_ascii=False)
|
||||
html_path = save_raw_html(result.get("html", ""))
|
||||
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)
|
||||
# ---- 网址缓存:命中直接返回历史,未命中才实时提取 ----------------
|
||||
settings = load_capture_settings()
|
||||
cache_cfg = settings.get("cache", DEFAULT_CACHE_CONFIG)
|
||||
retry_cfg = settings.get("retry", DEFAULT_RETRY_CONFIG)
|
||||
force_refresh = bool(data.get("refresh") or data.get("force"))
|
||||
|
||||
if cache_cfg.get("enabled") and not force_refresh:
|
||||
ttl = int(cache_cfg.get("ttl_seconds", 3600))
|
||||
hit = find_cache_hit(url, action, ttl)
|
||||
if hit:
|
||||
hit["from_cache"] = True
|
||||
hit["cached_at"] = hit["created_at"]
|
||||
hit["cache_ttl_seconds"] = ttl
|
||||
if action == "screenshot":
|
||||
if hit["file_path"] and Path(hit["file_path"]).exists():
|
||||
return send_file(hit["file_path"], mimetype='image/png')
|
||||
# 文件已丢则继续实时提取
|
||||
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)
|
||||
title = result.get("title", "")
|
||||
|
||||
|
||||
if not result["success"]:
|
||||
save_capture(url, title, action, backend_used, "failed", error=result.get("error", ""),
|
||||
caller=caller, call_method=call_method)
|
||||
@@ -1588,18 +1717,27 @@ def capture():
|
||||
|
||||
# 每次提取后都把最原始 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":
|
||||
persisted = persist_screenshot(result["file_path"])
|
||||
save_capture(url, title, action, backend_used, "success", file_path=persisted,
|
||||
html_path=html_path, caller=caller, call_method=call_method)
|
||||
return send_file(persisted, mimetype='image/png')
|
||||
|
||||
|
||||
elif action == "html":
|
||||
save_capture(url, title, action, backend_used, "success", content=result["html"],
|
||||
html_path=html_path, caller=caller, call_method=call_method)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
elif action == "text":
|
||||
save_capture(url, title, action, backend_used, "success", content=result["text"],
|
||||
html_path=html_path, caller=caller, call_method=call_method)
|
||||
|
||||
Reference in New Issue
Block a user