Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c63887e10 | ||
|
|
e5ff90c46a |
@@ -7,6 +7,7 @@ Web Capture API - 网页截图与代码提取服务
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import shutil
|
||||
@@ -60,9 +61,11 @@ CAPTURE_DIR.mkdir(exist_ok=True)
|
||||
PROJECT_DIR = Path(__file__).parent
|
||||
DATA_DIR = PROJECT_DIR / "data"
|
||||
CAPTURE_DATA_DIR = DATA_DIR / "captures"
|
||||
HTML_DATA_DIR = DATA_DIR / "html" # 原始HTML按月归档目录
|
||||
HISTORY_DB = DATA_DIR / "history.db"
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
CAPTURE_DATA_DIR.mkdir(exist_ok=True)
|
||||
HTML_DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def get_db():
|
||||
@@ -88,20 +91,31 @@ def init_db():
|
||||
file_path TEXT DEFAULT '',
|
||||
content TEXT DEFAULT '',
|
||||
error TEXT DEFAULT '',
|
||||
caller TEXT DEFAULT '游客',
|
||||
call_method TEXT DEFAULT 'api',
|
||||
html_path TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# 兼容旧库:缺列则 ALTER TABLE 补充
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(captures)").fetchall()]
|
||||
for col, ddl in (("caller", "TEXT DEFAULT '游客'"),
|
||||
("call_method", "TEXT DEFAULT 'api'"),
|
||||
("html_path", "TEXT DEFAULT ''")):
|
||||
if col not in cols:
|
||||
conn.execute(f"ALTER TABLE captures ADD COLUMN {col} {ddl}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_capture(url, title, action, backend, status, file_path='', content='', error=''):
|
||||
"""保存一条提取历史记录"""
|
||||
def save_capture(url, title, action, backend, status, file_path='', content='', error='',
|
||||
caller='游客', call_method='api', html_path=''):
|
||||
"""保存一条提取历史记录(含调用者/调用方式/原始HTML路径)"""
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO captures (url, title, action, backend, status, file_path, content, error, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(url, title, action, backend, status, file_path, content, error,
|
||||
"INSERT INTO captures (url, title, action, backend, status, file_path, content, error, caller, call_method, html_path, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(url, title, action, backend, status, file_path, content, error, caller, call_method, html_path,
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
)
|
||||
conn.commit()
|
||||
@@ -110,6 +124,22 @@ def save_capture(url, title, action, backend, status, file_path='', content='',
|
||||
return rid
|
||||
|
||||
|
||||
def save_raw_html(html, url=''):
|
||||
"""把最原始 HTML 保存到 data/html/<YYYY-MM>/ 按月目录归档;失败返回 ''"""
|
||||
if not html:
|
||||
return ''
|
||||
try:
|
||||
month = datetime.now().strftime("%Y-%m")
|
||||
month_dir = HTML_DATA_DIR / month
|
||||
month_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}.html"
|
||||
dest = month_dir / name
|
||||
dest.write_text(html if isinstance(html, str) else str(html), encoding="utf-8")
|
||||
return str(dest)
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def persist_screenshot(src_path):
|
||||
"""把临时截图复制到持久化目录,供历史记录长期访问"""
|
||||
name = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}.png"
|
||||
@@ -130,6 +160,123 @@ def clean_text(raw):
|
||||
|
||||
init_db()
|
||||
|
||||
# ===== 按需截图(AI智能判断滚动)配置 =====
|
||||
CONFIG_FILE = DATA_DIR / "config.json"
|
||||
|
||||
DEFAULT_SMART_CONFIG = {
|
||||
"base_url": "https://www.autodl.art/api/v1",
|
||||
"api_key": "F9MBfolzuapqTsD4KmUf9qen720rXvUZ3Sp3IrWiCTukqonx",
|
||||
"model": "qwen3.6-plus",
|
||||
"prompt": (
|
||||
"你是网页内容完整性判断助手。下面是一张网页滚动截图的当前视口画面。"
|
||||
"请判断:这个网页的主题内容(正文/主要内容)是否已经完整截取完成,是否还需要继续向下滚动?\n"
|
||||
"规则:\n"
|
||||
"1. 如果当前视口底部已经是页面底部(如页脚、版权信息、导航链接列表、没有更多正文),说明已完整 → complete=true\n"
|
||||
"2. 如果底部还有正文内容被截断、或还有更多正文需要滚动才能看到 → complete=false\n"
|
||||
"只输出JSON:{\"complete\": true/false, \"reason\": \"简短理由\"}"
|
||||
),
|
||||
"max_scrolls": 20,
|
||||
"scroll_ratio": 0.85, # 每次滚动视口高度的比例(重叠 15% 保证拼接连续)
|
||||
"timeout": 120
|
||||
}
|
||||
|
||||
|
||||
def load_smart_config():
|
||||
"""读取按需截图 LLM 配置(缺省回默认)"""
|
||||
cfg = json.loads(json.dumps(DEFAULT_SMART_CONFIG))
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
saved = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(saved, dict):
|
||||
for k in DEFAULT_SMART_CONFIG:
|
||||
if k in saved:
|
||||
cfg[k] = saved[k]
|
||||
except Exception:
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def save_smart_config(cfg):
|
||||
"""保存按需截图 LLM 配置"""
|
||||
merged = json.loads(json.dumps(DEFAULT_SMART_CONFIG))
|
||||
if isinstance(cfg, dict):
|
||||
for k in DEFAULT_SMART_CONFIG:
|
||||
if k in cfg and cfg[k] not in (None, ""):
|
||||
merged[k] = cfg[k]
|
||||
CONFIG_FILE.parent.mkdir(exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return merged
|
||||
|
||||
|
||||
def extract_json_from_content(content):
|
||||
"""从大模型输出中稳健提取 JSON 对象(兼容 ```json 代码块包裹、前后废话)"""
|
||||
if not content:
|
||||
return None
|
||||
text = str(content).strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```[a-zA-Z]*\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
s, e = text.find("{"), text.rfind("}")
|
||||
if s != -1 and e > s:
|
||||
text = text[s:e + 1]
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def llm_judge_screenshot(image_path, cfg):
|
||||
"""
|
||||
调用视觉大模型判断当前截图是否已覆盖主题内容、是否还需滚动
|
||||
返回: {"complete": bool, "reason": str}
|
||||
异常时抛 ValueError(调用方决定如何处置)
|
||||
"""
|
||||
import requests
|
||||
img_b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
|
||||
url = cfg["base_url"].rstrip("/") + "/chat/completions"
|
||||
payload = {
|
||||
"model": cfg["model"],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": cfg["prompt"]},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}}
|
||||
]
|
||||
}],
|
||||
"max_tokens": 300
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {cfg['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]}")
|
||||
try:
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
except Exception:
|
||||
raise ValueError(f"LLM 响应格式异常: {resp.text[:200]}")
|
||||
parsed = extract_json_from_content(content)
|
||||
if not parsed or "complete" not in parsed:
|
||||
raise ValueError(f"LLM 判断结果解析失败,原始输出: {content[:200]}")
|
||||
return {"complete": bool(parsed["complete"]), "reason": str(parsed.get("reason", ""))}
|
||||
|
||||
|
||||
def stitch_images(image_paths, out_path):
|
||||
"""把多张视口截图纵向拼接成一张长图(所有图对齐到最宽宽度)"""
|
||||
from PIL import Image
|
||||
images = [Image.open(p).convert("RGB") for p in image_paths]
|
||||
if not images:
|
||||
return None
|
||||
width = max(im.width for im in images)
|
||||
height = sum(im.height for im in images)
|
||||
canvas = Image.new("RGB", (width, height), "white")
|
||||
y = 0
|
||||
for im in images:
|
||||
canvas.paste(im, (0, y))
|
||||
y += im.height
|
||||
out_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
canvas.save(out_path)
|
||||
return str(out_path)
|
||||
|
||||
|
||||
# agent-browser socket 目录
|
||||
os.environ.setdefault("AGENT_BROWSER_SOCKET_DIR", "/tmp/agent-browser-sockets")
|
||||
Path(os.environ["AGENT_BROWSER_SOCKET_DIR"]).mkdir(exist_ok=True, parents=True)
|
||||
@@ -283,21 +430,32 @@ def capture_with_agent_browser(
|
||||
success, _, error = session.screenshot(temp_file, full_page=full_page)
|
||||
|
||||
if success and temp_file.exists():
|
||||
return {"success": True, "title": title, "file_path": str(temp_file)}
|
||||
res = {"success": True, "title": title, "file_path": str(temp_file)}
|
||||
# 顺带抓取最原始 HTML 存档
|
||||
ok, html_out, _ = session.get_html()
|
||||
if ok and html_out:
|
||||
res["html"] = session._decode_eval(html_out)
|
||||
return res
|
||||
else:
|
||||
return {"success": False, "title": title, "error": f"Screenshot failed: {error}"}
|
||||
|
||||
elif action == "html":
|
||||
success, html, error = session.get_html()
|
||||
if success:
|
||||
return {"success": True, "title": title, "html": html}
|
||||
# 修复:eval 结果按 JSON 编码返回,需解码成最原始 HTML
|
||||
return {"success": True, "title": title, "html": session._decode_eval(html)}
|
||||
else:
|
||||
return {"success": False, "title": title, "error": f"Get HTML failed: {error}"}
|
||||
|
||||
elif action == "text":
|
||||
success, raw_text, error = session.get_text()
|
||||
if success:
|
||||
return {"success": True, "title": title, "text": clean_text(raw_text)}
|
||||
res = {"success": True, "title": title, "text": clean_text(raw_text)}
|
||||
# 顺带抓取最原始 HTML 存档
|
||||
ok, html_out, _ = session.get_html()
|
||||
if ok and html_out:
|
||||
res["html"] = session._decode_eval(html_out)
|
||||
return res
|
||||
else:
|
||||
return {"success": False, "title": title, "error": f"Extract text failed: {error}"}
|
||||
|
||||
@@ -474,6 +632,11 @@ async def capture_with_playwright(
|
||||
temp_file = CAPTURE_DIR / f"{session_id}.png"
|
||||
await page.screenshot(path=str(temp_file), full_page=full_page)
|
||||
result = {"success": True, "title": title, "file_path": str(temp_file)}
|
||||
# 顺带抓取最原始 HTML 存档
|
||||
try:
|
||||
result["html"] = await page.content()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif action == "html":
|
||||
html = await page.content()
|
||||
@@ -482,6 +645,11 @@ async def capture_with_playwright(
|
||||
elif action == "text":
|
||||
raw_text = await page.evaluate("document.body.innerText")
|
||||
result = {"success": True, "title": title, "text": clean_text(raw_text)}
|
||||
# 顺带抓取最原始 HTML 存档
|
||||
try:
|
||||
result["html"] = await page.content()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else:
|
||||
result = {"success": False, "title": title, "error": f"Unknown action: {action}"}
|
||||
@@ -546,6 +714,250 @@ async def capture_with_cdp(
|
||||
return {"success": False, "error": f"CDP connection failed: {str(e)}"}
|
||||
|
||||
|
||||
def _scroll_pixels(view_h, ratio):
|
||||
"""按视口高度与比例计算单次滚动像素(下限 50)"""
|
||||
return max(50, int(view_h * float(ratio)))
|
||||
|
||||
|
||||
def smart_capture_agent_browser(url, cfg, wait_time, viewport):
|
||||
"""agent-browser 后端:滚动截图 + 视觉大模型实时判断"""
|
||||
session = AgentBrowserSession()
|
||||
vw = viewport.get("width", 1280) if viewport else 1280
|
||||
vh = viewport.get("height", 700) if viewport else 700
|
||||
steps = []
|
||||
shot_paths = []
|
||||
max_scrolls = int(cfg.get("max_scrolls", 20))
|
||||
stop_reason = ""
|
||||
|
||||
try:
|
||||
session.set_viewport(vw, vh)
|
||||
success, _, err = session.open(url)
|
||||
if not success:
|
||||
return {"success": False, "error": f"打开网页失败: {err}"}
|
||||
session.wait(wait_time)
|
||||
|
||||
# 先回到顶部
|
||||
session.run(["eval", "window.scrollTo(0, 0)"], timeout=5000)
|
||||
session.wait(300)
|
||||
|
||||
for i in range(max_scrolls):
|
||||
step_no = i + 1
|
||||
shot = CAPTURE_DIR / f"smart_{session.session_id}_{step_no}.png"
|
||||
ok, _, serr = session.screenshot(shot)
|
||||
if not ok or not shot.exists():
|
||||
return {"success": False, "error": f"第 {step_no} 步截图失败: {serr}"}
|
||||
shot_paths.append(shot)
|
||||
|
||||
# 视觉大模型实时判断
|
||||
try:
|
||||
judge = llm_judge_screenshot(shot, cfg)
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"]})
|
||||
except ValueError as ve:
|
||||
# 判断失败:保守停止,保留已截内容
|
||||
steps.append({"step": step_no, "complete": True, "reason": f"⚠️ 大模型判断失败,停止滚动: {ve}"})
|
||||
stop_reason = str(ve)
|
||||
break
|
||||
|
||||
if judge["complete"]:
|
||||
break
|
||||
|
||||
# 记录滚动前位置,防止页面无法再滚动
|
||||
try:
|
||||
_, before, _ = session.run(["eval", "window.scrollY"], timeout=5000)
|
||||
before = float(session._decode_eval(before) or 0)
|
||||
except Exception:
|
||||
before = -1
|
||||
|
||||
session.scroll_down(_scroll_pixels(vh, cfg.get("scroll_ratio", 0.85)))
|
||||
session.wait(int(wait_time) if wait_time else 500)
|
||||
|
||||
if before >= 0:
|
||||
try:
|
||||
_, after_raw, _ = session.run(["eval", "window.scrollY"], timeout=5000)
|
||||
after = float(session._decode_eval(after_raw) or before)
|
||||
if after <= before:
|
||||
steps.append({"step": step_no, "complete": True, "reason": "已滚动到页面底部(滚动位置不再变化)"})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
steps.append({"step": max_scrolls, "complete": True, "reason": "达到最大滚动次数上限,停止"})
|
||||
|
||||
# 拼接长图
|
||||
out = CAPTURE_DATA_DIR / f"smart_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}.png"
|
||||
stitched = stitch_images(shot_paths, out)
|
||||
title = session.get_title()
|
||||
|
||||
res = {
|
||||
"success": True,
|
||||
"title": title,
|
||||
"file_path": stitched,
|
||||
"steps": steps,
|
||||
"total_steps": len(shot_paths),
|
||||
"stop_reason": stop_reason,
|
||||
"backend": "agent-browser"
|
||||
}
|
||||
# 顺带抓取最原始 HTML 存档
|
||||
ok, html_out, _ = session.get_html()
|
||||
if ok and html_out:
|
||||
res["html"] = session._decode_eval(html_out)
|
||||
return res
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
async def smart_capture_playwright(url, cfg, wait_time, viewport):
|
||||
"""playwright 后端:滚动截图 + 视觉大模型实时判断"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
return {"success": False, "error": "Playwright not installed"}
|
||||
vw = viewport.get("width", 1280) if viewport else 1280
|
||||
vh = viewport.get("height", 700) if viewport else 700
|
||||
steps = []
|
||||
shot_paths = []
|
||||
max_scrolls = int(cfg.get("max_scrolls", 20))
|
||||
stop_reason = ""
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
try:
|
||||
browser = await p.chromium.launch(
|
||||
headless=True, channel='chrome',
|
||||
args=['--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage',
|
||||
'--no-sandbox', '--ignore-certificate-errors']
|
||||
)
|
||||
except Exception:
|
||||
browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
args=['--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage',
|
||||
'--no-sandbox', '--ignore-certificate-errors']
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": vw, "height": vh},
|
||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.109 Safari/537.36'
|
||||
)
|
||||
page = await context.new_page()
|
||||
if STEALTH_AVAILABLE:
|
||||
try:
|
||||
from playwright_stealth import stealth_async
|
||||
await stealth_async(page)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||
except Exception:
|
||||
try:
|
||||
await page.goto(url, wait_until="commit", timeout=30000)
|
||||
except Exception:
|
||||
pass
|
||||
if wait_time > 0:
|
||||
await page.wait_for_timeout(wait_time)
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=5000)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await page.evaluate("window.scrollTo(0, 0)")
|
||||
await page.wait_for_timeout(300)
|
||||
except Exception:
|
||||
# 页面导航导致上下文销毁,等稳定后再试
|
||||
try:
|
||||
await page.wait_for_timeout(800)
|
||||
await page.evaluate("window.scrollTo(0, 0)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _safe_scroll_y():
|
||||
try:
|
||||
return await page.evaluate("window.scrollY")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for i in range(max_scrolls):
|
||||
step_no = i + 1
|
||||
shot = CAPTURE_DIR / f"smart_pw_{uuid.uuid4().hex[:8]}_{step_no}.png"
|
||||
try:
|
||||
await page.screenshot(path=str(shot))
|
||||
except Exception:
|
||||
try:
|
||||
await page.wait_for_timeout(800)
|
||||
await page.screenshot(path=str(shot))
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"第 {step_no} 步截图失败: {e}"}
|
||||
shot_paths.append(shot)
|
||||
|
||||
try:
|
||||
judge = llm_judge_screenshot(shot, cfg)
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"]})
|
||||
except ValueError as ve:
|
||||
steps.append({"step": step_no, "complete": True, "reason": f"⚠️ 大模型判断失败,停止滚动: {ve}"})
|
||||
stop_reason = str(ve)
|
||||
break
|
||||
|
||||
if judge["complete"]:
|
||||
break
|
||||
|
||||
before = await _safe_scroll_y()
|
||||
try:
|
||||
await page.evaluate(f"window.scrollBy(0, {_scroll_pixels(vh, cfg.get('scroll_ratio', 0.85))})")
|
||||
await page.wait_for_timeout(wait_time if wait_time else 500)
|
||||
except Exception:
|
||||
# 滚动失败视为到页面底部
|
||||
steps.append({"step": step_no, "complete": True, "reason": "页面导航/滚动异常,视为已到末尾"})
|
||||
break
|
||||
after = await _safe_scroll_y()
|
||||
if before is not None and after is not None and after <= before:
|
||||
steps.append({"step": step_no, "complete": True, "reason": "已滚动到页面底部(滚动位置不再变化)"})
|
||||
break
|
||||
else:
|
||||
steps.append({"step": max_scrolls, "complete": True, "reason": "达到最大滚动次数上限,停止"})
|
||||
|
||||
out = CAPTURE_DATA_DIR / f"smart_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}.png"
|
||||
stitched = stitch_images(shot_paths, out)
|
||||
try:
|
||||
title = await page.title()
|
||||
except Exception:
|
||||
title = ""
|
||||
res = {
|
||||
"success": True,
|
||||
"title": title,
|
||||
"file_path": stitched,
|
||||
"steps": steps,
|
||||
"total_steps": len(shot_paths),
|
||||
"stop_reason": stop_reason,
|
||||
"backend": "playwright"
|
||||
}
|
||||
# 顺带抓取最原始 HTML 存档
|
||||
try:
|
||||
res["html"] = await page.content()
|
||||
except Exception:
|
||||
pass
|
||||
await browser.close()
|
||||
return res
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def smart_capture(url, cfg, wait_time, viewport, backend="auto"):
|
||||
"""按需截图总入口:滚动截图 + 视觉大模型实时判断,返回拼接长图"""
|
||||
if backend == "auto":
|
||||
backend = "agent-browser" if AGENT_BROWSER_AVAILABLE else "playwright"
|
||||
|
||||
if backend == "agent-browser":
|
||||
return smart_capture_agent_browser(url, cfg, wait_time, viewport)
|
||||
elif backend == "playwright":
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
result = loop.run_until_complete(smart_capture_playwright(url, cfg, wait_time, viewport))
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
return {"success": False, "error": f"Unknown backend: {backend}"}
|
||||
|
||||
|
||||
def capture_webpage(
|
||||
url: str,
|
||||
action: str = "screenshot",
|
||||
@@ -620,10 +1032,11 @@ def api_info():
|
||||
"playwright": "available" if PLAYWRIGHT_AVAILABLE else "unavailable"
|
||||
},
|
||||
"endpoints": {
|
||||
"/api/capture": "POST - Capture webpage (screenshot/html/text) + 自动入库历史",
|
||||
"/api/capture": "POST - Capture webpage (screenshot/html/text/smart) + 自动入库历史(记录调用者/方式/原始HTML)",
|
||||
"/api/history": "GET - 历史记录分页列表 (?page&page_size&action&search)",
|
||||
"/api/history/<id>": "GET - 历史记录详情 / DELETE - 删除记录",
|
||||
"/api/history/<id>/file": "GET - 读取历史截图文件",
|
||||
"/api/history/<id>/html": "GET - 读取保存的原始HTML文件",
|
||||
"/health": "GET - Health check"
|
||||
}
|
||||
})
|
||||
@@ -654,7 +1067,7 @@ def history_list():
|
||||
conn = get_db()
|
||||
total = conn.execute(f"SELECT COUNT(*) AS c FROM captures{where_sql}", params).fetchone()["c"]
|
||||
rows = conn.execute(
|
||||
f"SELECT id, url, title, action, backend, status, file_path, created_at, "
|
||||
f"SELECT id, url, title, action, backend, status, file_path, caller, call_method, html_path, created_at, "
|
||||
f"LENGTH(content) AS content_size, substr(content, 1, 200) AS content_preview, error "
|
||||
f"FROM captures{where_sql} ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
params + [page_size, (page - 1) * page_size]
|
||||
@@ -694,22 +1107,36 @@ def history_file(rid):
|
||||
return send_file(r["file_path"], mimetype='image/png')
|
||||
|
||||
|
||||
@app.route('/api/history/<int:rid>/html', methods=['GET'])
|
||||
def history_html_file(rid):
|
||||
"""读取保存的原始 HTML 文件(data/html/<月份>/xxx.html)"""
|
||||
conn = get_db()
|
||||
r = conn.execute("SELECT html_path FROM captures WHERE id=?", (rid,)).fetchone()
|
||||
conn.close()
|
||||
if not r or not r["html_path"] or not Path(r["html_path"]).exists():
|
||||
return jsonify({"success": False, "error": "原始HTML文件不存在或已删除"}), 404
|
||||
return send_file(r["html_path"], mimetype='text/html; charset=utf-8')
|
||||
|
||||
|
||||
@app.route('/api/history/<int:rid>', methods=['DELETE'])
|
||||
def history_delete(rid):
|
||||
"""删除历史记录(连带删除截图文件)"""
|
||||
"""删除历史记录(连带删除截图文件 / 原始HTML文件)"""
|
||||
conn = get_db()
|
||||
r = conn.execute("SELECT file_path FROM captures WHERE id=?", (rid,)).fetchone()
|
||||
r = conn.execute("SELECT file_path, html_path FROM captures WHERE id=?", (rid,)).fetchone()
|
||||
if r:
|
||||
conn.execute("DELETE FROM captures WHERE id=?", (rid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if r and r["file_path"]:
|
||||
fp = Path(r["file_path"])
|
||||
if fp.exists() and str(fp).startswith(str(CAPTURE_DATA_DIR)):
|
||||
try:
|
||||
fp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
if r:
|
||||
for fp_s in (r["file_path"], r["html_path"]):
|
||||
if not fp_s:
|
||||
continue
|
||||
fp = Path(fp_s)
|
||||
if fp.exists() and str(fp).startswith(str(DATA_DIR)):
|
||||
try:
|
||||
fp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@@ -725,6 +1152,51 @@ def health():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/smart/config', methods=['GET', 'POST'])
|
||||
def smart_config_endpoint():
|
||||
"""获取 / 保存按需截图 LLM 配置"""
|
||||
if request.method == 'GET':
|
||||
cfg = load_smart_config()
|
||||
return jsonify({"success": True, "config": cfg})
|
||||
data = request.get_json() or {}
|
||||
cfg = save_smart_config(data.get("config") or data)
|
||||
return jsonify({"success": True, "config": cfg})
|
||||
|
||||
|
||||
@app.route('/api/smart/test', methods=['POST'])
|
||||
def smart_test_endpoint():
|
||||
"""测试按需截图 LLM 接口连通性(发一条小文本消息验证 base_url/api_key/model)"""
|
||||
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]
|
||||
|
||||
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"}
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=int(cfg.get("timeout", 120)))
|
||||
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
|
||||
d = resp.json()
|
||||
try:
|
||||
reply = d["choices"][0]["message"]["content"][:80]
|
||||
except Exception:
|
||||
reply = "(无内容)"
|
||||
return jsonify({"success": True, "latency": cost, "model": cfg["model"], "reply": reply})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
|
||||
@app.route('/api/capture', methods=['POST'])
|
||||
def capture():
|
||||
"""
|
||||
@@ -733,14 +1205,15 @@ def capture():
|
||||
请求体:
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"action": "screenshot" | "html",
|
||||
"action": "screenshot" | "html" | "text" | "smart",
|
||||
"scroll_times": 0,
|
||||
"scroll_delay": 1000,
|
||||
"full_page": false,
|
||||
"viewport": {"width": 1920, "height": 1080},
|
||||
"wait_time": 2000,
|
||||
"backend": "auto" | "agent-browser" | "playwright" | "chrome-cdp",
|
||||
"cdp_port": 9222 // 用于 chrome-cdp 后端,连接到已打开的 Chrome
|
||||
"cdp_port": 9222, // 用于 chrome-cdp 后端,连接到已打开的 Chrome
|
||||
"smart_config": {...} // 可选,按需截图时临时覆盖 LLM 配置
|
||||
}
|
||||
"""
|
||||
data = request.get_json()
|
||||
@@ -757,6 +1230,8 @@ def capture():
|
||||
url = "https://" + url
|
||||
|
||||
action = data.get("action", "screenshot")
|
||||
if action not in ("screenshot", "html", "text", "smart"):
|
||||
return jsonify({"success": False, "error": f"Unknown action: {action}(可选 screenshot/html/text/smart)"}), 400
|
||||
scroll_times = int(data.get("scroll_times", 0))
|
||||
scroll_delay = int(data.get("scroll_delay", 1000))
|
||||
full_page = bool(data.get("full_page", False))
|
||||
@@ -765,7 +1240,45 @@ def capture():
|
||||
backend = data.get("backend", "auto")
|
||||
cdp_port = int(data.get("cdp_port", 9222))
|
||||
url_hint = data.get("url_hint", "")
|
||||
|
||||
|
||||
# ---- 调用者与调用方式识别 ----
|
||||
# 调用者:优先请求体 caller,其次请求头 X-Caller/X-Project/X-App,默认游客
|
||||
caller = (data.get("caller") or request.headers.get("X-Caller")
|
||||
or request.headers.get("X-Project") or request.headers.get("X-App")
|
||||
or "游客")
|
||||
# 调用方式:请求体 call_method 或请求头 X-From;缺省按 Referer 判断(本前端=web,否则 api)
|
||||
call_method = data.get("call_method") or request.headers.get("X-From") or ""
|
||||
if not call_method:
|
||||
ref = request.headers.get("Referer") or ""
|
||||
call_method = "web" if (ref and request.host in ref) else "api"
|
||||
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)
|
||||
|
||||
result = capture_webpage(
|
||||
url=url,
|
||||
action=action,
|
||||
@@ -781,20 +1294,27 @@ def capture():
|
||||
title = result.get("title", "")
|
||||
|
||||
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)
|
||||
return jsonify(result), 400
|
||||
|
||||
# 每次提取后都把最原始 HTML 按月归档到本地
|
||||
html_path = save_raw_html(result.get("html", ""))
|
||||
|
||||
if action == "screenshot":
|
||||
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)
|
||||
return send_file(persisted, mimetype='image/png')
|
||||
|
||||
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)
|
||||
return jsonify(result)
|
||||
|
||||
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)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
|
||||
+245
-16
@@ -370,6 +370,16 @@
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.badge-api {
|
||||
background: #ede7f6;
|
||||
color: #5e35b1;
|
||||
}
|
||||
|
||||
.badge-web {
|
||||
background: #e0f7fa;
|
||||
color: #00695c;
|
||||
}
|
||||
|
||||
.history-url {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
@@ -556,7 +566,9 @@
|
||||
.history-table th:nth-child(2),
|
||||
.history-table td:nth-child(2),
|
||||
.history-table th:nth-child(3),
|
||||
.history-table td:nth-child(3) {
|
||||
.history-table td:nth-child(3),
|
||||
.history-table th:nth-child(4),
|
||||
.history-table td:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -591,8 +603,16 @@
|
||||
<input type="radio" name="action" value="text">
|
||||
📝 提取文本
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="action" value="smart">
|
||||
🧠 按需截图(AI判断滚动)
|
||||
</label>
|
||||
<button type="button" class="btn-small" onclick="openSmartConfig()" style="margin-top:0;" title="配置视觉大模型接口">⚙️ 智能配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="smartHint" style="display:none; padding:12px 16px; background:#eef2ff; border-radius:8px; color:#333; font-size:14px; line-height:1.8;">
|
||||
🧠 <strong>按需截图模式</strong>:每次截图后由视觉大模型实时判断——是否已截全主题内容、还需不需要继续往下滚动,自动滚动直到大模型判定完成,最后拼成一张长图。大模型接口可在「⚙️ 智能配置」中随时修改(默认已配置 qwen3.6-plus)。
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>高级选项</label>
|
||||
@@ -669,6 +689,7 @@
|
||||
<option value="screenshot">📸 截图</option>
|
||||
<option value="html">📄 HTML</option>
|
||||
<option value="text">📝 文本</option>
|
||||
<option value="smart">🧠 按需截图</option>
|
||||
</select>
|
||||
<input type="text" class="search-input" id="historySearch" placeholder="🔍 搜索网址/标题..." onkeydown="if(event.key==='Enter')loadHistory(1)">
|
||||
<button class="btn-small" onclick="loadHistory(1)">搜索</button>
|
||||
@@ -684,6 +705,8 @@
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>类型</th>
|
||||
<th>调用者</th>
|
||||
<th>方式</th>
|
||||
<th>网址</th>
|
||||
<th>标题</th>
|
||||
<th>状态</th>
|
||||
@@ -703,31 +726,94 @@
|
||||
<h3>📚 使用说明</h3>
|
||||
<p><strong>重要提示:</strong></p>
|
||||
<ul style="margin: 10px 0; line-height: 1.8;">
|
||||
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图(大模型接口可在「⚙️ 智能配置」修改)</li>
|
||||
<li>📝 <strong>提取文本</strong>:自动剔除脚本/样式/标签,得到干净的前端可读文本,适合直接用于分析/喂给大模型</li>
|
||||
<li>⏱️ <strong>页面加载等待</strong>:某些网站需要验证过程(如 Cloudflare),请设置较长时间(10000-30000ms)</li>
|
||||
<li>🔄 <strong>滚动次数</strong>:用于加载动态内容(如微博、推特等),建议 3-5 次</li>
|
||||
<li>🎯 <strong>全页截图</strong>:滚动加载后建议开启此选项</li>
|
||||
<li>📜 <strong>提取历史</strong>:每次提取自动入库,支持分页浏览、按类型筛选、搜索与删除</li>
|
||||
<li>📜 <strong>提取历史</strong>:每次提取自动入库,支持分页浏览、按类型筛选、搜索与删除;每条记录会标注<strong>调用者</strong>(游客/项目名)与<strong>调用方式</strong>(web/api),并自动把<strong>最原始 HTML</strong> 按月归档到 <code>data/html/<月份>/</code>(详情里可点「📄 原始HTML文件」查看)</li>
|
||||
<li>👤 <strong>API 调用方标识</strong>:请求体带 <code>caller</code>(或请求头 <code>X-Caller</code>/<code>X-Project</code>)即可在历史里显示项目名;带 <code>call_method</code> 或 <code>X-From</code> 标注调用方式,缺省按 Referer 自动判定 web/api</li>
|
||||
</ul>
|
||||
<p style="margin-top: 15px;"><strong>API 调用:</strong></p>
|
||||
<pre><code>POST /api/capture
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"action": "text", // "screenshot" | "html" | "text"
|
||||
"action": "smart", // "screenshot" | "html" | "text" | "smart"
|
||||
"wait_time": 15000, // 重要!验证网站需设置较长等待
|
||||
"scroll_times": 3, // 可选:加载动态内容
|
||||
"scroll_delay": 1000, // 可选:滚动间隔
|
||||
"full_page": true, // 可选:全页截图
|
||||
"backend": "playwright" // 可选:playwright / agent-browser
|
||||
"backend": "playwright", // 可选:playwright / agent-browser
|
||||
"caller": "news-tracker", // 可选:调用者标识(历史记录里显示,默认游客)
|
||||
"call_method": "api", // 可选:调用方式 web/api(缺省自动判定)
|
||||
"viewport": {"width":1280,"height":700},
|
||||
"smart_config": { // 可选:临时覆盖按需截图 LLM 配置
|
||||
"base_url": "https://www.autodl.art/api/v1",
|
||||
"api_key": "sk-xxx",
|
||||
"model": "qwen3.6-plus",
|
||||
"max_scrolls": 20,
|
||||
"scroll_ratio": 0.85
|
||||
}
|
||||
}
|
||||
|
||||
GET /api/history?page=1&page_size=15&action=all&search=关键词 // 历史分页
|
||||
GET /api/history/<id> // 历史详情
|
||||
GET /api/history/<id>/file // 历史截图文件
|
||||
DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
GET /api/smart/config // 获取按需截图 LLM 配置
|
||||
POST /api/smart/config // 保存配置(body: {config:{...}})
|
||||
POST /api/smart/test // 测试连接(body: {config:{...}} 可选)
|
||||
GET /api/history?page=1&page_size=15&action=all&search=关键词 // 历史分页
|
||||
GET /api/history/<id> // 历史详情
|
||||
GET /api/history/<id>/file // 历史截图文件
|
||||
DELETE /api/history/<id> // 删除历史</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 智能截图配置弹窗 -->
|
||||
<div class="modal-overlay" id="smartCfgOverlay" onclick="if(event.target===this)closeSmartConfig()">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>⚙️ 智能截图 · 视觉大模型配置</h3>
|
||||
<button class="modal-close" onclick="closeSmartConfig()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="scBaseUrl">接口地址 base_url</label>
|
||||
<input type="text" id="scBaseUrl" placeholder="https://.../api/v1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scApiKey">API Key</label>
|
||||
<input type="password" id="scApiKey" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scModel">模型名称</label>
|
||||
<input type="text" id="scModel" placeholder="qwen3.6-plus">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scPrompt">判断提示词(告诉大模型如何判断是否截全)</label>
|
||||
<textarea id="scPrompt" rows="7" style="width:100%; padding:12px; border:2px solid #e0e0e0; border-radius:8px; font-size:14px; font-family:inherit; line-height:1.6;"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="scMaxScrolls">最大滚动次数</label>
|
||||
<input type="number" id="scMaxScrolls" min="1" max="100" value="20">
|
||||
</div>
|
||||
<div>
|
||||
<label for="scRatio">单次滚动比例(视口高度%)</label>
|
||||
<input type="number" id="scRatio" min="10" max="100" value="85">
|
||||
</div>
|
||||
<div>
|
||||
<label for="scTimeout">LLM 超时(秒)</label>
|
||||
<input type="number" id="scTimeout" min="10" max="600" value="120">
|
||||
</div>
|
||||
</div>
|
||||
<div id="scTestResult" style="margin-top:12px; font-size:14px; line-height:1.8;"></div>
|
||||
</div>
|
||||
<div class="actions" style="padding: 0 24px 20px;">
|
||||
<button class="btn btn-secondary" onclick="testSmartConfig()">🧪 测试连接</button>
|
||||
<button class="btn btn-secondary" onclick="saveSmartConfig()">💾 保存配置</button>
|
||||
<button class="btn btn-secondary" onclick="closeSmartConfig()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史详情弹窗 -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal">
|
||||
@@ -755,9 +841,102 @@ DELETE /api/history/<id> // 删除
|
||||
let currentPage = 1;
|
||||
let currentHistory = { page: 1, total: 0, total_pages: 1 };
|
||||
let modalRecord = null;
|
||||
let smartConfig = null;
|
||||
let lastSmartHistoryId = null;
|
||||
|
||||
// 页面加载时读取历史
|
||||
window.addEventListener('load', () => loadHistory(1));
|
||||
// 页面加载时读取历史 + 智能配置
|
||||
window.addEventListener('load', () => {
|
||||
loadHistory(1);
|
||||
loadSmartConfig();
|
||||
});
|
||||
|
||||
// 操作类型切换:智能模式显示提示条
|
||||
document.querySelectorAll('input[name="action"]').forEach(r => {
|
||||
r.addEventListener('change', () => {
|
||||
const smart = document.querySelector('input[name="action"]:checked').value === 'smart';
|
||||
document.getElementById('smartHint').style.display = smart ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== 智能截图配置 ===== */
|
||||
async function loadSmartConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/smart/config');
|
||||
const data = await res.json();
|
||||
if (data.success) smartConfig = data.config;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function openSmartConfig() {
|
||||
if (!smartConfig) return;
|
||||
document.getElementById('scBaseUrl').value = smartConfig.base_url || '';
|
||||
document.getElementById('scApiKey').value = smartConfig.api_key || '';
|
||||
document.getElementById('scModel').value = smartConfig.model || '';
|
||||
document.getElementById('scPrompt').value = smartConfig.prompt || '';
|
||||
document.getElementById('scMaxScrolls').value = smartConfig.max_scrolls || 20;
|
||||
document.getElementById('scRatio').value = Math.round((smartConfig.scroll_ratio || 0.85) * 100);
|
||||
document.getElementById('scTimeout').value = smartConfig.timeout || 120;
|
||||
document.getElementById('scTestResult').innerHTML = '';
|
||||
document.getElementById('smartCfgOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
function closeSmartConfig() {
|
||||
document.getElementById('smartCfgOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
function collectSmartConfigForm() {
|
||||
return {
|
||||
base_url: document.getElementById('scBaseUrl').value.trim(),
|
||||
api_key: document.getElementById('scApiKey').value.trim(),
|
||||
model: document.getElementById('scModel').value.trim(),
|
||||
prompt: document.getElementById('scPrompt').value.trim(),
|
||||
max_scrolls: parseInt(document.getElementById('scMaxScrolls').value) || 20,
|
||||
scroll_ratio: (parseInt(document.getElementById('scRatio').value) || 85) / 100,
|
||||
timeout: parseInt(document.getElementById('scTimeout').value) || 120
|
||||
};
|
||||
}
|
||||
|
||||
async function testSmartConfig() {
|
||||
const cfg = collectSmartConfigForm();
|
||||
const el = document.getElementById('scTestResult');
|
||||
el.innerHTML = '<span style="color:#667eea;">⏳ 正在测试连接...</span>';
|
||||
try {
|
||||
const res = await fetch('/api/smart/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: cfg })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
el.innerHTML = `<span style="color:#2e7d32;">✅ 连接成功!耗时 ${data.latency}s,模型 ${escapeHtml(data.model)} 回复:${escapeHtml(data.reply)}</span>`;
|
||||
} else {
|
||||
el.innerHTML = `<span style="color:#c62828;">❌ 测试失败:${escapeHtml(data.error || '未知错误')}</span>`;
|
||||
}
|
||||
} catch (err) {
|
||||
el.innerHTML = `<span style="color:#c62828;">❌ 测试失败:${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSmartConfig() {
|
||||
const cfg = collectSmartConfigForm();
|
||||
const el = document.getElementById('scTestResult');
|
||||
try {
|
||||
const res = await fetch('/api/smart/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: cfg })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
smartConfig = data.config;
|
||||
el.innerHTML = '<span style="color:#2e7d32;">✅ 配置已保存并生效</span>';
|
||||
} else {
|
||||
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(data.error || '未知错误')}</span>`;
|
||||
}
|
||||
} catch (err) {
|
||||
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -781,7 +960,8 @@ DELETE /api/history/<id> // 删除
|
||||
viewport: {
|
||||
width: viewportWidth,
|
||||
height: viewportHeight
|
||||
}
|
||||
},
|
||||
smart_config: action === 'smart' ? (smartConfig || {}) : undefined
|
||||
};
|
||||
|
||||
// 显示加载
|
||||
@@ -794,7 +974,9 @@ DELETE /api/history/<id> // 删除
|
||||
const response = await fetch('/api/capture', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'X-From': 'web',
|
||||
'X-Caller': '游客'
|
||||
},
|
||||
body: JSON.stringify(currentData)
|
||||
});
|
||||
@@ -815,6 +997,17 @@ DELETE /api/history/<id> // 删除
|
||||
currentBlob = new Blob([data.html], { type: 'text/html' });
|
||||
document.getElementById('resultContent').innerHTML =
|
||||
`<div class="result-code"><pre>${escapeHtml(data.html)}</pre></div>`;
|
||||
} else if (action === 'smart') {
|
||||
lastSmartHistoryId = data.history_id || null;
|
||||
currentBlob = new Blob([JSON.stringify(data.steps || [], null, 2)], { type: 'application/json' });
|
||||
const stepsHtml = (data.steps || []).map(s =>
|
||||
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}</li>`
|
||||
).join('');
|
||||
document.getElementById('resultContent').innerHTML =
|
||||
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} | 🧠 AI 共滚动 <strong>${data.total_steps || 0}</strong> 次判定完成,已拼接为长图(下方展示)</div>` +
|
||||
`<img src="/api/history/${data.history_id}/file" class="result-image" alt="智能截图长图" onerror="this.style.display='none'">` +
|
||||
`<details style="margin-top:12px;"><summary style="cursor:pointer;color:#667eea;font-weight:600;">📋 查看 AI 判断过程(${(data.steps || []).length} 步)</summary>` +
|
||||
`<ul style="margin:10px 0 0 20px;line-height:1.8;color:#333;font-size:14px;">${stepsHtml}</ul></details>`;
|
||||
} else {
|
||||
currentBlob = new Blob([data.text], { type: 'text/plain;charset=utf-8' });
|
||||
const lines = (data.text || '').split('\n').length;
|
||||
@@ -843,6 +1036,16 @@ DELETE /api/history/<id> // 删除
|
||||
}
|
||||
|
||||
function downloadResult() {
|
||||
if (!currentData) return;
|
||||
if (currentData.action === 'smart') {
|
||||
if (lastSmartHistoryId) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/history/${lastSmartHistoryId}/file`;
|
||||
a.download = 'smart_capture.png';
|
||||
a.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!currentBlob) return;
|
||||
|
||||
const url = URL.createObjectURL(currentBlob);
|
||||
@@ -872,6 +1075,7 @@ DELETE /api/history/<id> // 删除
|
||||
error.classList.remove('active');
|
||||
currentData = null;
|
||||
currentBlob = null;
|
||||
lastSmartHistoryId = null;
|
||||
}
|
||||
|
||||
/* ===== 历史记录 ===== */
|
||||
@@ -916,7 +1120,8 @@ DELETE /api/history/<id> // 删除
|
||||
const badges = {
|
||||
screenshot: '📸 截图',
|
||||
html: '📄 HTML',
|
||||
text: '📝 文本'
|
||||
text: '📝 文本',
|
||||
smart: '🧠 按需截图'
|
||||
};
|
||||
|
||||
body.innerHTML = data.records.map(r => {
|
||||
@@ -925,9 +1130,14 @@ DELETE /api/history/<id> // 删除
|
||||
const statusText = r.status === 'failed'
|
||||
? `<span style="color:#c62828;" title="${escapeHtml(r.error || '')}">失败</span>`
|
||||
: '<span style="color:#2e7d32;">成功</span>';
|
||||
const callerText = r.caller || '游客';
|
||||
const methodText = r.call_method || 'api';
|
||||
const methodBadge = methodText === 'web' ? 'badge-web' : 'badge-api';
|
||||
return `<tr>
|
||||
<td style="white-space:nowrap;">${escapeHtml(r.created_at)}</td>
|
||||
<td><span class="badge ${escapeHtml(badgeClass)}">${badgeText}</span></td>
|
||||
<td><span class="history-title" title="${escapeHtml(callerText)}">${escapeHtml(callerText)}</span></td>
|
||||
<td><span class="badge ${methodBadge}" style="font-size:11px;padding:2px 8px;">${escapeHtml(methodText)}</span></td>
|
||||
<td><a class="history-url" href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.url)}</a></td>
|
||||
<td><span class="history-title" title="${escapeHtml(r.title || '')}">${escapeHtml(r.title || '—')}</span></td>
|
||||
<td>${statusText}</td>
|
||||
@@ -987,11 +1197,17 @@ DELETE /api/history/<id> // 删除
|
||||
document.getElementById('modalTitle').textContent = (r.title || r.url || '记录详情');
|
||||
const badgeClass = r.status === 'failed' ? 'badge-failed' : ('badge-' + r.action);
|
||||
const badgeText = r.status === 'failed' ? '❌ 失败' : ({screenshot: '📸 截图', html: '📄 HTML', text: '📝 文本'}[r.action] || r.action);
|
||||
const htmlLink = r.html_path
|
||||
? `<span><a href="/api/history/${r.id}/html" target="_blank" style="color:#667eea;font-weight:600;" title="${escapeHtml(r.html_path)}">📄 原始HTML文件</a></span>`
|
||||
: '';
|
||||
document.getElementById('modalMeta').innerHTML =
|
||||
`<span>类型:<span class="badge ${badgeClass}">${badgeText}</span></span>` +
|
||||
`<span>时间:${escapeHtml(r.created_at)}</span>` +
|
||||
`<span>调用者:${escapeHtml(r.caller || '游客')}</span>` +
|
||||
`<span>方式:${escapeHtml(r.call_method || 'api')}</span>` +
|
||||
`<span>后端:${escapeHtml(r.backend || '—')}</span>` +
|
||||
`<span>状态:${r.status === 'failed' ? '<span style="color:#c62828;">失败</span>' : '<span style="color:#2e7d32;">成功</span>'}</span>`;
|
||||
`<span>状态:${r.status === 'failed' ? '<span style="color:#c62828;">失败</span>' : '<span style="color:#2e7d32;">成功</span>'}</span>` +
|
||||
htmlLink;
|
||||
|
||||
const body = document.getElementById('modalBody');
|
||||
if (r.status === 'failed') {
|
||||
@@ -1000,6 +1216,19 @@ DELETE /api/history/<id> // 删除
|
||||
body.innerHTML = `<img src="/api/history/${r.id}/file" class="result-image" alt="历史截图" onerror="this.parentElement.innerHTML='<p style="color:#c62828;">截图文件不存在或已删除</p>'">`;
|
||||
} else if (r.action === 'html') {
|
||||
body.innerHTML = `<div class="result-code"><pre>${escapeHtml(r.content || '')}</pre></div>`;
|
||||
} else if (r.action === 'smart') {
|
||||
let stepsHtml = '';
|
||||
let stepsCount = 0;
|
||||
try {
|
||||
const steps = JSON.parse(r.content || '[]');
|
||||
stepsCount = steps.length;
|
||||
stepsHtml = steps.map(s =>
|
||||
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}</li>`
|
||||
).join('');
|
||||
} catch (e) {}
|
||||
body.innerHTML =
|
||||
`<img src="/api/history/${r.id}/file" class="result-image" alt="智能截图长图" onerror="this.style.display='none'">` +
|
||||
(stepsHtml ? `<details open style="margin-top:14px;"><summary style="cursor:pointer;color:#667eea;font-weight:600;">📋 AI 判断过程(${stepsCount} 步)</summary><ul style="margin:10px 0 0 20px;line-height:1.8;color:#333;font-size:14px;">${stepsHtml}</ul></details>` : '');
|
||||
} else {
|
||||
body.innerHTML = `<div class="result-text">${escapeHtml(r.content || '')}</div>`;
|
||||
}
|
||||
@@ -1037,10 +1266,10 @@ DELETE /api/history/<id> // 删除
|
||||
|
||||
function downloadHistoryContent() {
|
||||
if (!modalRecord) return;
|
||||
if (modalRecord.action === 'screenshot') {
|
||||
if (modalRecord.action === 'screenshot' || modalRecord.action === 'smart') {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/history/${modalRecord.id}/file`;
|
||||
a.download = `capture_${modalRecord.id}.png`;
|
||||
a.download = modalRecord.action === 'smart' ? `smart_${modalRecord.id}.png` : `capture_${modalRecord.id}.png`;
|
||||
a.click();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user