Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0c042649 | ||
|
|
17431e86cf |
@@ -163,10 +163,28 @@ init_db()
|
||||
# ===== 按需截图(AI智能判断滚动)配置 =====
|
||||
CONFIG_FILE = DATA_DIR / "config.json"
|
||||
|
||||
# 预置视觉大模型接口(按列表顺序 = 优先级,越靠前越优先;失败自动降级到下一个)
|
||||
DEFAULT_ENDPOINTS = [
|
||||
{
|
||||
"id": "ep_local_qwen",
|
||||
"name": "本地 Qwen3.8-27B (18008)",
|
||||
"base_url": "http://121.40.164.32:18008/v1",
|
||||
"api_key": "xxxx",
|
||||
"model": "unsloth/Qwen3.8-27B-NVFP4",
|
||||
"enabled": True
|
||||
},
|
||||
{
|
||||
"id": "ep_siliconflow_kimi",
|
||||
"name": "SiliconFlow Kimi-K2.6",
|
||||
"base_url": "https://api.siliconflow.cn/v1",
|
||||
"api_key": "sk-fhpoexpptvjghpnphtaxbkhjwulzovoqfffbckcfscjmwhcg",
|
||||
"model": "Pro/moonshotai/Kimi-K2.6",
|
||||
"enabled": True
|
||||
}
|
||||
]
|
||||
|
||||
DEFAULT_SMART_CONFIG = {
|
||||
"base_url": "https://www.autodl.art/api/v1",
|
||||
"api_key": "F9MBfolzuapqTsD4KmUf9qen720rXvUZ3Sp3IrWiCTukqonx",
|
||||
"model": "qwen3.6-plus",
|
||||
"endpoints": [json.loads(json.dumps(ep)) for ep in DEFAULT_ENDPOINTS],
|
||||
"prompt": (
|
||||
"你是网页内容完整性判断助手。下面是一张网页滚动截图的当前视口画面。"
|
||||
"请判断:这个网页的主题内容(正文/主要内容)是否已经完整截取完成,是否还需要继续向下滚动?\n"
|
||||
@@ -182,7 +200,7 @@ DEFAULT_SMART_CONFIG = {
|
||||
|
||||
|
||||
def load_smart_config():
|
||||
"""读取按需截图 LLM 配置(缺省回默认)"""
|
||||
"""读取按需截图 LLM 配置(缺省回默认;旧单接口配置自动迁移为 endpoints[0])"""
|
||||
cfg = json.loads(json.dumps(DEFAULT_SMART_CONFIG))
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
@@ -193,16 +211,49 @@ def load_smart_config():
|
||||
cfg[k] = saved[k]
|
||||
except Exception:
|
||||
pass
|
||||
# 兼容旧版单接口配置:没有 endpoints 时,把 base_url/api_key/model 迁移为第一个接口
|
||||
eps = cfg.get("endpoints") or []
|
||||
if not eps and cfg.get("base_url"):
|
||||
eps = [{
|
||||
"id": "ep_legacy",
|
||||
"name": cfg.get("model") or cfg.get("base_url"),
|
||||
"base_url": cfg["base_url"],
|
||||
"api_key": cfg.get("api_key", ""),
|
||||
"model": cfg.get("model", ""),
|
||||
"enabled": True
|
||||
}]
|
||||
cfg["endpoints"] = eps
|
||||
return cfg
|
||||
|
||||
|
||||
def save_smart_config(cfg):
|
||||
"""保存按需截图 LLM 配置"""
|
||||
"""保存按需截图 LLM 配置(endpoints 数组顺序 = 优先级)"""
|
||||
merged = json.loads(json.dumps(DEFAULT_SMART_CONFIG))
|
||||
if isinstance(cfg, dict):
|
||||
for k in DEFAULT_SMART_CONFIG:
|
||||
# 滚动/提示词参数
|
||||
for k in ("prompt", "max_scrolls", "scroll_ratio", "timeout"):
|
||||
if k in cfg and cfg[k] not in (None, ""):
|
||||
merged[k] = cfg[k]
|
||||
# 接口列表:保留有效字段,补 id/name/enabled 默认值
|
||||
eps = cfg.get("endpoints")
|
||||
if isinstance(eps, list) and eps:
|
||||
clean = []
|
||||
for i, ep in enumerate(eps):
|
||||
if not isinstance(ep, dict) or not ep.get("base_url"):
|
||||
continue
|
||||
clean.append({
|
||||
"id": ep.get("id") or f"ep_{i+1}",
|
||||
"name": ep.get("name") or ep.get("model") or f"接口{i+1}",
|
||||
"base_url": ep["base_url"],
|
||||
"api_key": ep.get("api_key", ""),
|
||||
"model": ep.get("model", ""),
|
||||
"enabled": bool(ep.get("enabled", True))
|
||||
})
|
||||
if clean:
|
||||
merged["endpoints"] = clean
|
||||
elif isinstance(cfg.get("endpoints"), list) and not eps:
|
||||
# 显式传空列表 = 清空
|
||||
merged["endpoints"] = []
|
||||
CONFIG_FILE.parent.mkdir(exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return merged
|
||||
@@ -225,17 +276,18 @@ def extract_json_from_content(content):
|
||||
return None
|
||||
|
||||
|
||||
def llm_judge_screenshot(image_path, cfg):
|
||||
def llm_call_vision(image_path, cfg, endpoint):
|
||||
"""
|
||||
调用视觉大模型判断当前截图是否已覆盖主题内容、是否还需滚动
|
||||
用单个视觉大模型接口判断截图
|
||||
endpoint: {"base_url", "api_key", "model", "name"}
|
||||
返回: {"complete": bool, "reason": str}
|
||||
异常时抛 ValueError(调用方决定如何处置)
|
||||
任何失败(网络/HTTP/解析/格式)都抛 ValueError,由上层降级到下一个接口
|
||||
"""
|
||||
import requests
|
||||
img_b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
|
||||
url = cfg["base_url"].rstrip("/") + "/chat/completions"
|
||||
url = endpoint["base_url"].rstrip("/") + "/chat/completions"
|
||||
payload = {
|
||||
"model": cfg["model"],
|
||||
"model": endpoint.get("model", ""),
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
@@ -245,20 +297,45 @@ def llm_judge_screenshot(image_path, cfg):
|
||||
}],
|
||||
"max_tokens": 300
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
|
||||
headers = {"Authorization": f"Bearer {endpoint.get('api_key', '')}", "Content-Type": "application/json"}
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=int(cfg.get("timeout", 120)))
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"LLM 接口返回 {resp.status_code}: {resp.text[:200]}")
|
||||
raise ValueError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
try:
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
except Exception:
|
||||
raise ValueError(f"LLM 响应格式异常: {resp.text[:200]}")
|
||||
raise ValueError(f"响应格式异常: {resp.text[:200]}")
|
||||
parsed = extract_json_from_content(content)
|
||||
if not parsed or "complete" not in parsed:
|
||||
raise ValueError(f"LLM 判断结果解析失败,原始输出: {content[:200]}")
|
||||
raise ValueError(f"判断结果解析失败,原始输出: {content[:200]}")
|
||||
return {"complete": bool(parsed["complete"]), "reason": str(parsed.get("reason", ""))}
|
||||
|
||||
|
||||
def llm_judge_screenshot(image_path, cfg):
|
||||
"""
|
||||
调用视觉大模型判断当前截图是否已覆盖主题内容、是否还需滚动。
|
||||
按 endpoints 列表顺序(高→低优先级)逐个尝试,失败/报错/超时自动降级到下一个;
|
||||
全部失败才抛 ValueError(调用方保守停止滚动)。
|
||||
返回: {"complete": bool, "reason": str, "model": str, "used_endpoint": str}
|
||||
"""
|
||||
import requests # noqa: F401 (确保模块可用,实际在 llm_call_vision 中导入)
|
||||
endpoints = cfg.get("endpoints") or []
|
||||
enabled = [ep for ep in endpoints if ep.get("enabled", True)]
|
||||
if not enabled:
|
||||
raise ValueError("未配置可用的视觉大模型接口(请到「⚙️ 智能配置」添加)")
|
||||
errors = []
|
||||
for ep in enabled:
|
||||
ep_name = ep.get("name") or ep.get("model") or ep.get("base_url", "")
|
||||
try:
|
||||
result = llm_call_vision(image_path, cfg, ep)
|
||||
result["model"] = ep.get("model", "")
|
||||
result["used_endpoint"] = ep_name
|
||||
return result
|
||||
except Exception as e:
|
||||
errors.append(f"{ep_name}: {e}")
|
||||
raise ValueError("全部视觉大模型接口调用失败 → " + " | ".join(errors))
|
||||
|
||||
|
||||
def stitch_images(image_paths, out_path):
|
||||
"""把多张视口截图纵向拼接成一张长图(所有图对齐到最宽宽度)"""
|
||||
from PIL import Image
|
||||
@@ -764,7 +841,8 @@ def smart_capture_agent_browser(url, cfg, wait_time, viewport):
|
||||
# 视觉大模型实时判断
|
||||
try:
|
||||
judge = llm_judge_screenshot(shot, cfg)
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"]})
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"],
|
||||
"model": judge.get("model", ""), "endpoint": judge.get("used_endpoint", "")})
|
||||
except ValueError as ve:
|
||||
# 判断失败:保守停止,保留已截内容
|
||||
steps.append({"step": step_no, "complete": True, "reason": f"⚠️ 大模型判断失败,停止滚动: {ve}"})
|
||||
@@ -902,7 +980,8 @@ async def smart_capture_playwright(url, cfg, wait_time, viewport):
|
||||
|
||||
try:
|
||||
judge = llm_judge_screenshot(shot, cfg)
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"]})
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"],
|
||||
"model": judge.get("model", ""), "endpoint": judge.get("used_endpoint", "")})
|
||||
except ValueError as ve:
|
||||
steps.append({"step": step_no, "complete": True, "reason": f"⚠️ 大模型判断失败,停止滚动: {ve}"})
|
||||
stop_reason = str(ve)
|
||||
@@ -955,7 +1034,7 @@ async def smart_capture_playwright(url, cfg, wait_time, viewport):
|
||||
def smart_capture(url, cfg, wait_time, viewport, backend="auto"):
|
||||
"""按需截图总入口:滚动截图 + 视觉大模型实时判断,返回拼接长图"""
|
||||
if backend == "auto":
|
||||
backend = "agent-browser" if AGENT_BROWSER_AVAILABLE else "playwright"
|
||||
backend = "playwright" if PLAYWRIGHT_AVAILABLE else "agent-browser"
|
||||
|
||||
if backend == "agent-browser":
|
||||
result = smart_capture_agent_browser(url, cfg, wait_time, viewport)
|
||||
@@ -1002,10 +1081,10 @@ def capture_webpage(
|
||||
"""
|
||||
# 选择后端
|
||||
if backend == "auto":
|
||||
if AGENT_BROWSER_AVAILABLE:
|
||||
backend = "agent-browser"
|
||||
elif PLAYWRIGHT_AVAILABLE:
|
||||
if PLAYWRIGHT_AVAILABLE:
|
||||
backend = "playwright"
|
||||
elif AGENT_BROWSER_AVAILABLE:
|
||||
backend = "agent-browser"
|
||||
else:
|
||||
return {"success": False, "error": "No browser backend available. Install agent-browser or playwright."}
|
||||
|
||||
@@ -1049,9 +1128,19 @@ def capture_webpage(
|
||||
)
|
||||
loop.close()
|
||||
result["backend"] = "playwright"
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
result = {"success": False, "error": str(e)}
|
||||
# playwright 失败(auto 默认优先它)→ 自动切 agent-browser 兜底重试
|
||||
if (not result.get("success") and AGENT_BROWSER_AVAILABLE):
|
||||
try:
|
||||
fb = capture_with_agent_browser(
|
||||
url, action, scroll_times, scroll_delay, full_page, viewport, wait_time
|
||||
)
|
||||
fb["backend"] = "agent-browser"
|
||||
result = fb
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
elif backend == "chrome-cdp":
|
||||
# 连接已打开的 Chrome(需 --remote-debugging-port=<cdp_port>),归一化为单结果
|
||||
@@ -1111,11 +1200,12 @@ def api_backends():
|
||||
"default": "auto",
|
||||
"available": available,
|
||||
"unavailable": unavailable,
|
||||
"priority": available + unavailable,
|
||||
# auto 优先 playwright,其次 agent-browser,再 chrome-cdp
|
||||
"priority": ["playwright", "agent-browser", "chrome-cdp"],
|
||||
"labels": {
|
||||
"auto": "自动(推荐:优先 agent-browser,打开超时自动回退 Playwright)",
|
||||
"agent-browser": "agent-browser(Rust 版,反爬强、快,默认首选)",
|
||||
"playwright": "Playwright(Python 版,domcontentloaded 更宽容,慢页面更稳)",
|
||||
"auto": "自动(推荐:优先 Playwright,失败自动回退 agent-browser)",
|
||||
"playwright": "Playwright(Python 版,domcontentloaded 更宽容,慢页面更稳,默认首选)",
|
||||
"agent-browser": "agent-browser(Rust 版,反爬强、快,备选)",
|
||||
"chrome-cdp": "Chrome CDP(连接已打开的 Chrome,需 --remote-debugging-port 启动)"
|
||||
}
|
||||
})
|
||||
@@ -1134,7 +1224,10 @@ def api_info():
|
||||
"endpoints": {
|
||||
"/api/capture": "POST - Capture webpage (screenshot/html/text/smart) + 自动入库历史(记录调用者/方式/原始HTML)",
|
||||
"/api/backends": "GET - 可用后端列表与默认顺序(供前端渲染后端选择器)",
|
||||
"/api/smart/config": "GET/POST - 智能截图视觉大模型多接口配置(列表顺序=优先级,失败自动降级)",
|
||||
"/api/smart/test": "POST - 测试指定视觉接口连通性({endpoint_id} / {endpoint} / 旧版{config})",
|
||||
"/api/history": "GET - 历史记录分页列表 (?page&page_size&action&search)",
|
||||
"/api/history/stats": "GET - 历史图示统计 (?dimension=day|action|backend|status|caller|method&days=30)",
|
||||
"/api/history/<id>": "GET - 历史记录详情 / DELETE - 删除记录",
|
||||
"/api/history/<id>/file": "GET - 读取历史截图文件",
|
||||
"/api/history/<id>/html": "GET - 读取保存的原始HTML文件",
|
||||
@@ -1186,6 +1279,78 @@ def history_list():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/history/stats', methods=['GET'])
|
||||
def history_stats():
|
||||
"""
|
||||
提取历史图示统计
|
||||
参数:
|
||||
dimension: day(按天趋势) | action(操作类型) | backend(后端) | status(状态) | caller(调用者) | method(调用方式)
|
||||
days: 按天维度的天数范围(1-365, 默认30); 其他维度默认全量(也可传 days 只统计最近N天)
|
||||
返回: {success, dimension, days, total, success_count, success_rate, items:[{label, value}]}
|
||||
"""
|
||||
dimension = request.args.get('dimension', 'action')
|
||||
valid = ('day', 'action', 'backend', 'status', 'caller', 'method')
|
||||
if dimension not in valid:
|
||||
return jsonify({"success": False, "error": f"dimension 需为 {'/'.join(valid)}"}), 400
|
||||
days = min(365, max(1, int(request.args.get('days', 30))))
|
||||
|
||||
# 时间下限(可选):非 day 维度也可按 days 过滤
|
||||
from datetime import timedelta
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
conn = get_db()
|
||||
# 总量 / 成功率
|
||||
total = conn.execute("SELECT COUNT(*) c FROM captures").fetchone()["c"]
|
||||
sc = conn.execute("SELECT COUNT(*) c FROM captures WHERE status='success'").fetchone()["c"]
|
||||
|
||||
items = []
|
||||
if dimension == 'day':
|
||||
# 最近 days 天的每日提取量(缺0补0)
|
||||
rows = conn.execute(
|
||||
"SELECT substr(created_at,1,10) d, COUNT(*) c FROM captures WHERE created_at >= ? GROUP BY d ORDER BY d",
|
||||
(cutoff,)
|
||||
).fetchall()
|
||||
m = {r["d"]: r["c"] for r in rows}
|
||||
date_list = []
|
||||
for i in range(days - 1, -1, -1):
|
||||
date_list.append((datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"))
|
||||
items = [{"label": d, "value": m.get(d, 0)} for d in date_list]
|
||||
else:
|
||||
col = {"action": "action", "backend": "backend", "status": "status",
|
||||
"caller": "caller", "method": "call_method"}[dimension]
|
||||
rows = conn.execute(
|
||||
f"SELECT {col} k, COUNT(*) c FROM captures WHERE created_at >= ? GROUP BY {col} ORDER BY c DESC",
|
||||
(cutoff,)
|
||||
).fetchall()
|
||||
# caller 太多时只保留 Top 8 + 其他
|
||||
if dimension == 'caller':
|
||||
top = rows[:8]
|
||||
rest = sum(r["c"] for r in rows[8:])
|
||||
items = [{"label": r["k"] or "未知", "value": r["c"]} for r in top]
|
||||
if rest:
|
||||
items.append({"label": "其他", "value": rest})
|
||||
else:
|
||||
for r in rows:
|
||||
label = r["k"] or "(空)"
|
||||
if dimension == 'status':
|
||||
label = "成功" if r["k"] == "success" else ("失败" if r["k"] == "failed" else r["k"])
|
||||
elif dimension == 'action':
|
||||
label = {"screenshot": "📸 截图", "html": "📄 HTML", "text": "📝 文本", "smart": "🧠 按需截图"}.get(r["k"], r["k"])
|
||||
items.append({"label": label, "value": r["c"]})
|
||||
conn.close()
|
||||
|
||||
# 按天维度去 emoji(保持纯净日期标签)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"dimension": dimension,
|
||||
"days": days,
|
||||
"total": total,
|
||||
"success_count": sc,
|
||||
"success_rate": round(sc / total, 4) if total else 0,
|
||||
"items": items
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/history/<int:rid>', methods=['GET'])
|
||||
def history_detail(rid):
|
||||
"""历史记录详情(含完整内容)"""
|
||||
@@ -1266,25 +1431,46 @@ def smart_config_endpoint():
|
||||
|
||||
@app.route('/api/smart/test', methods=['POST'])
|
||||
def smart_test_endpoint():
|
||||
"""测试按需截图 LLM 接口连通性(发一条小文本消息验证 base_url/api_key/model)"""
|
||||
"""测试视觉大模型接口连通性(发一条小文本消息验证 base_url/api_key/model)。
|
||||
入参(三选一):
|
||||
{endpoint_id: "ep_xxx"} → 测试已保存配置中的某个接口
|
||||
{endpoint: {base_url, api_key, model}} → 直接测试传入的接口
|
||||
{config: {...}} 或 {base_url,...} → 兼容旧版单接口测试
|
||||
"""
|
||||
import requests
|
||||
data = request.get_json() or {}
|
||||
cfg = dict(load_smart_config())
|
||||
if data.get("config"):
|
||||
for k in DEFAULT_SMART_CONFIG:
|
||||
if k in data["config"] and data["config"][k] not in (None, ""):
|
||||
cfg[k] = data["config"][k]
|
||||
elif data.get("base_url") or data.get("api_key") or data.get("model"):
|
||||
for k in ("base_url", "api_key", "model"):
|
||||
if data.get(k):
|
||||
cfg[k] = data[k]
|
||||
ep = None
|
||||
|
||||
url = cfg["base_url"].rstrip("/") + "/chat/completions"
|
||||
payload = {"model": cfg["model"], "messages": [{"role": "user", "content": "ping"}], "max_tokens": 5}
|
||||
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
|
||||
# 1) endpoint_id:从已保存配置里找
|
||||
if data.get("endpoint_id"):
|
||||
saved = load_smart_config()
|
||||
for e in (saved.get("endpoints") or []):
|
||||
if e.get("id") == data["endpoint_id"]:
|
||||
ep = e
|
||||
break
|
||||
if not ep:
|
||||
return jsonify({"success": False, "error": f"接口 {data['endpoint_id']} 不存在"}), 404
|
||||
# 2) endpoint 对象:直接测试
|
||||
elif isinstance(data.get("endpoint"), dict) and data["endpoint"].get("base_url"):
|
||||
ep = data["endpoint"]
|
||||
# 3) 兼容旧版:config 或平铺字段
|
||||
else:
|
||||
cfg = dict(load_smart_config())
|
||||
override = data.get("config") or data
|
||||
if isinstance(override, dict):
|
||||
for k in ("base_url", "api_key", "model", "timeout"):
|
||||
if k in override and override[k] not in (None, ""):
|
||||
cfg[k] = override[k]
|
||||
ep = {"base_url": cfg["base_url"], "api_key": cfg.get("api_key", ""),
|
||||
"model": cfg.get("model", ""), "name": cfg.get("model", "")}
|
||||
|
||||
url = ep["base_url"].rstrip("/") + "/chat/completions"
|
||||
payload = {"model": ep.get("model", ""), "messages": [{"role": "user", "content": "ping"}], "max_tokens": 5}
|
||||
headers = {"Authorization": f"Bearer {ep.get('api_key', '')}", "Content-Type": "application/json"}
|
||||
timeout = int(data.get("timeout") or 60)
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=int(cfg.get("timeout", 120)))
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
cost = round(time.time() - t0, 2)
|
||||
if resp.status_code != 200:
|
||||
return jsonify({"success": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}", "latency": cost}), 400
|
||||
@@ -1293,7 +1479,7 @@ def smart_test_endpoint():
|
||||
reply = d["choices"][0]["message"]["content"][:80]
|
||||
except Exception:
|
||||
reply = "(无内容)"
|
||||
return jsonify({"success": True, "latency": cost, "model": cfg["model"], "reply": reply})
|
||||
return jsonify({"success": True, "latency": cost, "model": ep.get("model", ""), "reply": reply})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
|
||||
+396
-69
@@ -611,7 +611,7 @@
|
||||
</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)。
|
||||
🧠 <strong>按需截图模式</strong>:每次截图后由视觉大模型实时判断——是否已截全主题内容、还需不需要继续往下滚动,自动滚动直到大模型判定完成,最后拼成一张长图。支持配置<strong>多个视觉大模型接口</strong>(按优先级调用,失败自动降级到下一个),在「⚙️ 智能配置」中随时增删改。
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -630,7 +630,7 @@
|
||||
<option value="auto">加载中…</option>
|
||||
</select>
|
||||
<small id="backendHint" style="color: #666; display: block; margin-top: 4px;">
|
||||
自动 = 优先 agent-browser,打开页面超时自动回退 Playwright(推荐)
|
||||
自动 = 优先 Playwright,失败自动回退 agent-browser(推荐)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -704,6 +704,7 @@
|
||||
<input type="text" class="search-input" id="historySearch" placeholder="🔍 搜索网址/标题..." onkeydown="if(event.key==='Enter')loadHistory(1)">
|
||||
<button class="btn-small" onclick="loadHistory(1)">搜索</button>
|
||||
<button class="btn-small" onclick="refreshHistory()">🔄 刷新</button>
|
||||
<button class="btn-small" onclick="openStats()">📊 统计</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -736,7 +737,8 @@
|
||||
<h3>📚 使用说明</h3>
|
||||
<p><strong>重要提示:</strong></p>
|
||||
<ul style="margin: 10px 0; line-height: 1.8;">
|
||||
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图(大模型接口可在「⚙️ 智能配置」修改)</li>
|
||||
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图;支持<strong>多视觉大模型接口配置</strong>(「⚙️ 智能配置」中增删改,列表顺序=优先级,失败/报错自动降级到下一个接口)</li>
|
||||
<li>📊 <strong>提取历史统计</strong>:历史区点「📊 统计」,可按天/操作类型/后端/状态/调用者/调用方式等角度切换图表(柱状/饼图可换,支持 7/30/90 天范围,可下载 PNG)</li>
|
||||
<li>📝 <strong>提取文本</strong>:自动剔除脚本/样式/标签,得到干净的前端可读文本,适合直接用于分析/喂给大模型</li>
|
||||
<li>⏱️ <strong>页面加载等待</strong>:某些网站需要验证过程(如 Cloudflare),请设置较长时间(10000-30000ms)</li>
|
||||
<li>🔄 <strong>滚动次数</strong>:用于加载动态内容(如微博、推特等),建议 3-5 次</li>
|
||||
@@ -753,30 +755,32 @@
|
||||
"scroll_times": 3, // 可选:加载动态内容
|
||||
"scroll_delay": 1000, // 可选:滚动间隔
|
||||
"full_page": true, // 可选:全页截图
|
||||
"backend": "playwright", // 可选:playwright / agent-browser
|
||||
"backend": "auto", // 可选:auto(默认,优先playwright,失败回退agent-browser) / playwright / agent-browser / chrome-cdp
|
||||
"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",
|
||||
"smart_config": { // 可选:临时覆盖按需截图配置(接口列表按顺序=优先级,失败自动降级)
|
||||
"endpoints": [
|
||||
{"name":"本地Qwen", "base_url":"http://121.40.164.32:18008/v1", "api_key":"xxxx", "model":"unsloth/Qwen3.8-27B-NVFP4", "enabled":true},
|
||||
{"name":"Kimi", "base_url":"https://api.siliconflow.cn/v1", "api_key":"sk-xxx", "model":"Pro/moonshotai/Kimi-K2.6", "enabled":true}
|
||||
],
|
||||
"max_scrolls": 20,
|
||||
"scroll_ratio": 0.85
|
||||
}
|
||||
}
|
||||
|
||||
GET /api/smart/config // 获取按需截图 LLM 配置
|
||||
POST /api/smart/config // 保存配置(body: {config:{...}})
|
||||
POST /api/smart/test // 测试连接(body: {config:{...}} 可选)
|
||||
GET /api/smart/config // 获取智能截图配置(含 endpoints 多接口列表)
|
||||
POST /api/smart/config // 保存配置(body: {config:{endpoints:[...], prompt, max_scrolls,...}})
|
||||
POST /api/smart/test // 测试指定接口(body: {endpoint_id} 或 {endpoint:{...}} 或旧版 {config:{...}})
|
||||
GET /api/history?page=1&page_size=15&action=all&search=关键词 // 历史分页
|
||||
GET /api/history/stats?dimension=day&days=30 // 历史统计(dimension: day/action/backend/status/caller/method)
|
||||
GET /api/history/<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">
|
||||
@@ -784,46 +788,73 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
<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 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 class="form-group">
|
||||
<label for="scApiKey">API Key</label>
|
||||
<input type="password" id="scApiKey" placeholder="sk-...">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
||||
<strong style="color:#333;">🎯 视觉大模型接口列表(均需含视觉能力)</strong>
|
||||
<button class="btn-small" onclick="addSmartEndpoint()">➕ 添加接口</button>
|
||||
</div>
|
||||
<div 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 id="epList" style="display:flex;flex-direction:column;gap:10px;"></div>
|
||||
<div style="margin-top:18px;border-top:1px dashed #ddd;padding-top:16px;">
|
||||
<div class="form-group" style="margin-bottom:12px;">
|
||||
<label for="scPrompt">判断提示词(告诉大模型如何判断是否截全)</label>
|
||||
<textarea id="scPrompt" rows="6" style="width:100%; padding:12px; border:2px solid #e0e0e0; border-radius:8px; font-size:14px; font-family:inherit; line-height:1.6;"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<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 class="row">
|
||||
<div>
|
||||
<label for="scMaxScrolls">最大滚动次数</label>
|
||||
<input type="number" id="scMaxScrolls" min="1" max="100" value="20">
|
||||
</div>
|
||||
<div>
|
||||
<label for="scRatio">单次滚动比例(视口高度%)</label>
|
||||
<input type="number" id="scRatio" min="10" max="100" value="85">
|
||||
</div>
|
||||
<div>
|
||||
<label for="scTimeout">LLM 超时(秒)</label>
|
||||
<input type="number" id="scTimeout" min="10" max="600" value="120">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scTestResult" style="margin-top:12px; font-size:14px; line-height:1.8;"></div>
|
||||
</div>
|
||||
<div class="actions" style="padding: 0 24px 20px;">
|
||||
<button class="btn btn-secondary" onclick="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="statsOverlay" onclick="if(event.target===this)closeStats()">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>📊 提取历史统计</h3>
|
||||
<button class="modal-close" onclick="closeStats()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px;">
|
||||
<span style="font-size:13px;color:#666;font-weight:600;">角度:</span>
|
||||
<div id="statDimBtns" style="display:flex;gap:6px;flex-wrap:wrap;"></div>
|
||||
<span style="font-size:13px;color:#666;font-weight:600;margin-left:10px;">范围:</span>
|
||||
<div id="statDaysBtns" style="display:flex;gap:6px;"></div>
|
||||
<span style="font-size:13px;color:#666;font-weight:600;margin-left:10px;">图型:</span>
|
||||
<div id="statTypeBtns" style="display:flex;gap:6px;"></div>
|
||||
<button class="btn-small" onclick="refreshStats()" style="margin-left:auto;">🔄 刷新</button>
|
||||
</div>
|
||||
<div id="statSummary" style="display:flex;gap:18px;flex-wrap:wrap;margin-bottom:12px;font-size:14px;color:#333;"></div>
|
||||
<div style="background:#fff;border:1px solid #eee;border-radius:10px;padding:10px;">
|
||||
<canvas id="statsCanvas" style="width:100%;height:340px;display:block;"></canvas>
|
||||
</div>
|
||||
<div id="statTable" style="margin-top:14px;"></div>
|
||||
</div>
|
||||
<div class="actions" style="padding: 0 24px 20px;">
|
||||
<button class="btn btn-secondary" onclick="downloadStatsPng()">💾 下载图表</button>
|
||||
<button class="btn btn-secondary" onclick="closeStats()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史详情弹窗 -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal">
|
||||
@@ -891,7 +922,9 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== 智能截图配置 ===== */
|
||||
/* ===== 智能截图配置(多接口 + 优先级降级) ===== */
|
||||
let smartEndpoints = [];
|
||||
|
||||
async function loadSmartConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/smart/config');
|
||||
@@ -900,44 +933,90 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
} 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 smartEndpointNewId() {
|
||||
return 'ep_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
|
||||
function closeSmartConfig() {
|
||||
document.getElementById('smartCfgOverlay').classList.remove('active');
|
||||
function renderSmartEndpoints() {
|
||||
const list = document.getElementById('epList');
|
||||
if (!smartEndpoints.length) {
|
||||
list.innerHTML = '<div style="padding:20px;text-align:center;color:#999;border:1.5px dashed #ddd;border-radius:8px;">暂无接口,点击「➕ 添加接口」添加(按需截图至少需要一个可用接口)</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = smartEndpoints.map((ep, i) => `
|
||||
<div style="border:1px solid #e0e0e0;border-radius:10px;padding:12px;background:#fafbff;">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
|
||||
<span style="background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border-radius:50%;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex-shrink:0;" title="优先级序号(越小越优先)">${i+1}</span>
|
||||
<label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer;font-weight:600;color:#333;flex-shrink:0;">
|
||||
<input type="checkbox" ${ep.enabled ? 'checked' : ''} onchange="toggleSmartEndpoint('${ep.id}')">启用
|
||||
</label>
|
||||
<input type="text" value="${escapeHtml(ep.name || '')}" placeholder="接口名称" onchange="smartEndpointField('${ep.id}','name',this.value)" style="flex:1;min-width:140px;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
|
||||
<span style="display:flex;gap:4px;flex-shrink:0;">
|
||||
<button class="btn-small" onclick="moveSmartEndpoint('${ep.id}',-1)" ${i === 0 ? 'disabled' : ''} title="提升优先级">⬆</button>
|
||||
<button class="btn-small" onclick="moveSmartEndpoint('${ep.id}',1)" ${i === smartEndpoints.length - 1 ? 'disabled' : ''} title="降低优先级">⬇</button>
|
||||
<button class="btn-small" onclick="removeSmartEndpoint('${ep.id}')" style="background:#dc3545;" title="删除接口">🗑</button>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">base_url</label>
|
||||
<input type="text" value="${escapeHtml(ep.base_url || '')}" placeholder="https://.../v1" onchange="smartEndpointField('${ep.id}','base_url',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">API Key</label>
|
||||
<input type="text" value="${escapeHtml(ep.api_key || '')}" placeholder="sk-... 或 xxxx" onchange="smartEndpointField('${ep.id}','api_key',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">模型名称 model</label>
|
||||
<input type="text" value="${escapeHtml(ep.model || '')}" placeholder="qwen3.8-27B..." onchange="smartEndpointField('${ep.id}','model',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end;">
|
||||
<button class="btn-small" onclick="testSmartEndpoint('${ep.id}')" style="width:100%;padding:9px;">🧪 测试连接</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="epTest_${ep.id}" style="font-size:13px;line-height:1.7;margin-top:6px;"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function 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
|
||||
};
|
||||
function smartEndpointField(id, key, val) {
|
||||
const ep = smartEndpoints.find(e => e.id === id);
|
||||
if (ep) ep[key] = val;
|
||||
}
|
||||
|
||||
async function testSmartConfig() {
|
||||
const cfg = collectSmartConfigForm();
|
||||
const el = document.getElementById('scTestResult');
|
||||
function toggleSmartEndpoint(id) {
|
||||
const ep = smartEndpoints.find(e => e.id === id);
|
||||
if (ep) ep.enabled = !ep.enabled;
|
||||
}
|
||||
|
||||
function moveSmartEndpoint(id, dir) {
|
||||
const i = smartEndpoints.findIndex(e => e.id === id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= smartEndpoints.length) return;
|
||||
[smartEndpoints[i], smartEndpoints[j]] = [smartEndpoints[j], smartEndpoints[i]];
|
||||
renderSmartEndpoints();
|
||||
}
|
||||
|
||||
function addSmartEndpoint() {
|
||||
smartEndpoints.push({ id: smartEndpointNewId(), name: '', base_url: '', api_key: '', model: '', enabled: true });
|
||||
renderSmartEndpoints();
|
||||
const last = document.getElementById('epList').lastElementChild;
|
||||
if (last) last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function removeSmartEndpoint(id) {
|
||||
smartEndpoints = smartEndpoints.filter(e => e.id !== id);
|
||||
renderSmartEndpoints();
|
||||
}
|
||||
|
||||
async function testSmartEndpoint(id) {
|
||||
const el = document.getElementById('epTest_' + id);
|
||||
el.innerHTML = '<span style="color:#667eea;">⏳ 正在测试连接...</span>';
|
||||
try {
|
||||
const res = await fetch('/api/smart/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: cfg })
|
||||
body: JSON.stringify({ endpoint_id: id })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
@@ -950,9 +1029,39 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
}
|
||||
}
|
||||
|
||||
function openSmartConfig() {
|
||||
if (!smartConfig) return;
|
||||
smartEndpoints = (smartConfig.endpoints || []).map(ep => JSON.parse(JSON.stringify(ep)));
|
||||
document.getElementById('scPrompt').value = smartConfig.prompt || '';
|
||||
document.getElementById('scMaxScrolls').value = smartConfig.max_scrolls || 20;
|
||||
document.getElementById('scRatio').value = Math.round((smartConfig.scroll_ratio || 0.85) * 100);
|
||||
document.getElementById('scTimeout').value = smartConfig.timeout || 120;
|
||||
document.getElementById('scTestResult').innerHTML = '';
|
||||
renderSmartEndpoints();
|
||||
document.getElementById('smartCfgOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
function closeSmartConfig() {
|
||||
document.getElementById('smartCfgOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
function collectSmartConfigForm() {
|
||||
return {
|
||||
endpoints: smartEndpoints.map(ep => ({
|
||||
id: ep.id, name: ep.name, base_url: ep.base_url,
|
||||
api_key: ep.api_key, model: ep.model, enabled: ep.enabled !== false
|
||||
})),
|
||||
prompt: document.getElementById('scPrompt').value.trim(),
|
||||
max_scrolls: parseInt(document.getElementById('scMaxScrolls').value) || 20,
|
||||
scroll_ratio: (parseInt(document.getElementById('scRatio').value) || 85) / 100,
|
||||
timeout: parseInt(document.getElementById('scTimeout').value) || 120
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSmartConfig() {
|
||||
const cfg = collectSmartConfigForm();
|
||||
const el = document.getElementById('scTestResult');
|
||||
el.innerHTML = '<span style="color:#667eea;">⏳ 正在保存...</span>';
|
||||
try {
|
||||
const res = await fetch('/api/smart/config', {
|
||||
method: 'POST',
|
||||
@@ -962,7 +1071,8 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
smartConfig = data.config;
|
||||
el.innerHTML = '<span style="color:#2e7d32;">✅ 配置已保存并生效</span>';
|
||||
const enabled = (data.config.endpoints || []).filter(e => e.enabled !== false).length;
|
||||
el.innerHTML = `✅ 配置已保存并生效(共 ${(data.config.endpoints || []).length} 个接口,启用 ${enabled} 个,按列表顺序作为优先级)`;
|
||||
} else {
|
||||
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(data.error || '未知错误')}</span>`;
|
||||
}
|
||||
@@ -971,6 +1081,223 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 历史图示统计 ===== */
|
||||
let statsDim = 'day';
|
||||
let statsDays = 30;
|
||||
let statsType = 'bar';
|
||||
let statsData = null;
|
||||
const STAT_COLORS = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#43e97b', '#fa709a', '#ffd86f', '#5ee7df', '#c471f5', '#f6d365'];
|
||||
|
||||
function openStats() {
|
||||
document.getElementById('statsOverlay').classList.add('active');
|
||||
renderStatsControls();
|
||||
refreshStats();
|
||||
}
|
||||
|
||||
function closeStats() {
|
||||
document.getElementById('statsOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
function renderStatsControls() {
|
||||
const dims = [['day', '📅 按天'], ['action', '🎯 操作类型'], ['backend', '🖥 后端'], ['status', '✅ 状态'], ['caller', '👤 调用者'], ['method', '🔀 调用方式']];
|
||||
document.getElementById('statDimBtns').innerHTML = dims.map(([v, l]) =>
|
||||
`<button class="btn-small" style="${v === statsDim ? 'background:#667eea;' : ''}" onclick="setStatsDim('${v}')">${l}</button>`
|
||||
).join('');
|
||||
document.getElementById('statDaysBtns').innerHTML = [7, 30, 90].map(d =>
|
||||
`<button class="btn-small" style="${d === statsDays ? 'background:#667eea;' : ''}" onclick="setStatsDays(${d})">${d}天</button>`
|
||||
).join('');
|
||||
document.getElementById('statTypeBtns').innerHTML = [['pie', '🍩 饼图'], ['bar', '📊 柱状']].map(([t, l]) =>
|
||||
`<button class="btn-small" style="${t === statsType ? 'background:#667eea;' : ''}" onclick="setStatsType('${t}')">${l}</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function setStatsDim(d) {
|
||||
statsDim = d;
|
||||
statsType = d === 'day' ? 'bar' : 'pie'; // 维度切换时默认图型
|
||||
renderStatsControls();
|
||||
refreshStats();
|
||||
}
|
||||
|
||||
function setStatsDays(d) { statsDays = d; renderStatsControls(); refreshStats(); }
|
||||
|
||||
function setStatsType(t) { statsType = t; renderStatsControls(); renderStatsChart(); }
|
||||
|
||||
async function refreshStats() {
|
||||
try {
|
||||
const res = await fetch(`/api/history/stats?dimension=${statsDim}&days=${statsDays}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || '加载失败');
|
||||
statsData = data;
|
||||
renderStatsSummary();
|
||||
renderStatsChart();
|
||||
renderStatsTable();
|
||||
} catch (err) {
|
||||
document.getElementById('statSummary').innerHTML = `<span style="color:#c62828;">❌ 加载统计失败:${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatsSummary() {
|
||||
if (!statsData) return;
|
||||
const rate = (statsData.success_rate * 100).toFixed(1);
|
||||
const dimLabel = {day: '近', action: '', backend: '', status: '', caller: '', method: ''}[statsData.dimension];
|
||||
document.getElementById('statSummary').innerHTML =
|
||||
`<span>📈 统计总数:<strong>${statsData.total}</strong> 条</span>` +
|
||||
`<span>✅ 成功:<strong>${statsData.success_count}</strong> 条(成功率 ${rate}%)</span>` +
|
||||
(statsData.dimension === 'day' ? `<span>📅 近 <strong>${statsData.days}</strong> 天提取量</span>` : '');
|
||||
}
|
||||
|
||||
function renderStatsChart() {
|
||||
const canvas = document.getElementById('statsCanvas');
|
||||
if (!statsData || !statsData.items || !statsData.items.length) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr; canvas.height = 340 * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, 340);
|
||||
ctx.fillStyle = '#999'; ctx.font = '15px sans-serif'; ctx.textAlign = 'center';
|
||||
ctx.fillText('暂无数据', rect.width / 2, 170);
|
||||
return;
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const W = Math.max(200, rect.width), H = 340;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
if (statsType === 'pie') drawStatsPie(ctx, W, H, statsData.items);
|
||||
else drawStatsBar(ctx, W, H, statsData.items, statsData.dimension);
|
||||
}
|
||||
|
||||
function drawStatsPie(ctx, W, H, items) {
|
||||
const total = items.reduce((s, it) => s + it.value, 0);
|
||||
if (!total) return;
|
||||
const legendW = 300;
|
||||
const cx = Math.min(W - legendW, W * 0.42) / 2 + 40, cy = H / 2;
|
||||
const r = Math.min(H / 2 - 30, (W - legendW) / 2 - 60);
|
||||
let angle = -Math.PI / 2;
|
||||
items.forEach((it, i) => {
|
||||
const a = it.value / total * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, r, angle, angle + a);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
|
||||
angle += a;
|
||||
});
|
||||
// 中心文字
|
||||
ctx.fillStyle = '#333'; ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 28px sans-serif'; ctx.fillText(String(total), cx, cy - 4);
|
||||
ctx.font = '13px sans-serif'; ctx.fillStyle = '#999'; ctx.fillText('总提取量', cx, cy + 22);
|
||||
// 图例
|
||||
let lx = cx + r + 28, ly = H / 2 - ((items.length - 1) * 26) / 2;
|
||||
ctx.textAlign = 'left';
|
||||
items.forEach((it, i) => {
|
||||
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
|
||||
ctx.fillRect(lx, ly - 8, 14, 14);
|
||||
const pct = (it.value / total * 100).toFixed(1);
|
||||
const label = it.label.length > 16 ? it.label.slice(0, 15) + '…' : it.label;
|
||||
ctx.fillStyle = '#333'; ctx.font = '13px sans-serif';
|
||||
ctx.fillText(`${label} ${it.value} (${pct}%)`, lx + 20, ly + 3);
|
||||
ly += 26;
|
||||
});
|
||||
}
|
||||
|
||||
function drawStatsBar(ctx, W, H, items, dimension) {
|
||||
if (dimension === 'day') {
|
||||
drawDayBar(ctx, W, H, items);
|
||||
} else {
|
||||
drawCatBar(ctx, W, H, items);
|
||||
}
|
||||
}
|
||||
|
||||
function drawDayBar(ctx, W, H, items) {
|
||||
const left = 52, right = 16, top = 18, bottom = 34;
|
||||
const cw = W - left - right, ch = H - top - bottom;
|
||||
const max = Math.max(1, ...items.map(it => it.value));
|
||||
// 网格线 + Y 轴
|
||||
ctx.strokeStyle = '#eee'; ctx.fillStyle = '#999'; ctx.font = '11px sans-serif'; ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const v = Math.round(max * i / 4);
|
||||
const y = top + ch - ch * i / 4;
|
||||
ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(W - right, y); ctx.stroke();
|
||||
ctx.fillText(String(v), left - 6, y + 4);
|
||||
}
|
||||
const n = items.length;
|
||||
const step = Math.max(1, Math.ceil(n / Math.max(6, Math.floor(cw / 60))));
|
||||
const bw = cw / n;
|
||||
items.forEach((it, i) => {
|
||||
const h = Math.max(0, ch * it.value / max);
|
||||
const x = left + i * bw + bw * 0.15, w = Math.max(2, bw * 0.7);
|
||||
const g = ctx.createLinearGradient(0, top + ch - h, 0, top + ch);
|
||||
g.addColorStop(0, '#667eea'); g.addColorStop(1, '#a78bfa');
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, top + ch - h, w, h, 3);
|
||||
ctx.fill();
|
||||
if (it.value > 0) {
|
||||
ctx.fillStyle = '#555'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
|
||||
ctx.fillText(String(it.value), x + w / 2, top + ch - h - 3);
|
||||
}
|
||||
// X 轴日期标签(稀疏)
|
||||
if (i % step === 0) {
|
||||
ctx.fillStyle = '#888'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
|
||||
ctx.save();
|
||||
ctx.translate(x + w / 2, top + ch + 10);
|
||||
ctx.rotate(-0.5);
|
||||
ctx.fillText(it.label.slice(5), 0, 0); // MM-DD
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawCatBar(ctx, W, H, items) {
|
||||
const maxLabel = Math.max(...items.map(it => it.label.length));
|
||||
const left = Math.min(W * 0.4, Math.max(120, maxLabel * 13 + 20));
|
||||
const right = 70, top = 12, bottom = 16;
|
||||
const cw = W - left - right, ch = H - top - bottom;
|
||||
const max = Math.max(1, ...items.map(it => it.value));
|
||||
const rowH = Math.min(40, ch / items.length);
|
||||
const startY = top + (ch - rowH * items.length) / 2;
|
||||
items.forEach((it, i) => {
|
||||
const y = startY + i * rowH;
|
||||
const bw = Math.max(2, cw * it.value / max);
|
||||
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(left, y + 6, bw, rowH - 12, 4);
|
||||
ctx.fill();
|
||||
const label = it.label.length > 14 ? it.label.slice(0, 13) + '…' : it.label;
|
||||
ctx.fillStyle = '#333'; ctx.font = '13px sans-serif'; ctx.textAlign = 'right';
|
||||
ctx.fillText(label, left - 8, y + rowH / 2 + 4);
|
||||
ctx.fillStyle = '#555'; ctx.textAlign = 'left';
|
||||
ctx.fillText(String(it.value), left + bw + 8, y + rowH / 2 + 4);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatsTable() {
|
||||
const el = document.getElementById('statTable');
|
||||
if (!statsData || !statsData.items || !statsData.items.length) { el.innerHTML = ''; return; }
|
||||
const items = statsData.items;
|
||||
if (items.length > 12) { el.innerHTML = ''; return; } // 分类多/按天时不渲染表,避免太长
|
||||
const total = items.reduce((s, it) => s + it.value, 0) || 1;
|
||||
el.innerHTML = `<table class="history-table" style="font-size:13px;">
|
||||
<thead><tr><th>项</th><th>数量</th><th>占比</th></tr></thead>
|
||||
<tbody>${items.map(it =>
|
||||
`<tr><td>${escapeHtml(it.label)}</td><td><strong>${it.value}</strong></td><td>${(it.value / total * 100).toFixed(1)}%</td></tr>`
|
||||
).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function downloadStatsPng() {
|
||||
const canvas = document.getElementById('statsCanvas');
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = `stats_${statsDim}_${statsDays}d_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1034,7 +1361,7 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
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>`
|
||||
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}${s.model ? `<span style="color:#888;font-size:12px;">(模型:${escapeHtml(s.model)}</span>${s.endpoint ? `<span style="color:#888;font-size:12px;"> / ${escapeHtml(s.endpoint)}</span>` : ''}<span style="color:#888;font-size:12px;">)</span>` : ''}</li>`
|
||||
).join('');
|
||||
document.getElementById('resultContent').innerHTML =
|
||||
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} | 🧠 AI 共滚动 <strong>${data.total_steps || 0}</strong> 次判定完成,已拼接为长图(下方展示)</div>` +
|
||||
@@ -1256,7 +1583,7 @@ DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
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>`
|
||||
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}${s.model ? `<span style="color:#888;font-size:12px;">(模型:${escapeHtml(s.model)}</span>${s.endpoint ? `<span style="color:#888;font-size:12px;"> / ${escapeHtml(s.endpoint)}</span>` : ''}<span style="color:#888;font-size:12px;">)</span>` : ''}</li>`
|
||||
).join('');
|
||||
} catch (e) {}
|
||||
body.innerHTML =
|
||||
|
||||
Reference in New Issue
Block a user