Files
web-capture-api/app.py
T
hz4th_coder 82ff8a2610 v1.7.3 前端新增后端选择器 + 暴露 /api/backends + 接通 chrome-cdp 后端
- 新增 GET /api/backends 返回可用后端/默认顺序/说明,供前端动态渲染
- 前端表单新增「捕获后端」下拉框: 自动(默认,优先agent-browser超时自动回退playwright)/agent-browser/playwright/chrome-cdp,不可用的置灰
- 接通 chrome-cdp 后端: 之前函数已实现但 capture_webpage 未接线(传 backend=chrome-cdp 会报 Unknown backend),现支持 screenshot/html/text,连接已开的 Chrome(--remote-debugging-port)
- 前端 currentData 携带 backend 字段
2026-08-31 16:56:22 +08:00

1436 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/home/hz1/miniconda3/envs/openclaw/bin/python3.12
"""
Web Capture API - 网页截图与代码提取服务
使用 agent-browser (Rust + Playwright) 或 Playwright
"""
import os
import re
import json
import base64
import asyncio
import sqlite3
import shutil
import subprocess
import tempfile
import uuid
import time
from pathlib import Path
from flask import Flask, request, jsonify, send_file, render_template
from flask_cors import CORS
from datetime import datetime
# 检查 Playwright 是否可用
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
# 尝试导入 stealth 反检测
try:
from playwright_stealth import stealth_async
STEALTH_AVAILABLE = True
except ImportError:
STEALTH_AVAILABLE = False
print("⚠️ playwright-stealth 未安装,反爬虫能力降低")
except ImportError:
PLAYWRIGHT_AVAILABLE = False
STEALTH_AVAILABLE = False
print("⚠️ Playwright 未安装,请运行: pip install playwright && playwright install chromium")
# 检查 agent-browser 是否可用
def check_agent_browser():
try:
result = subprocess.run(
["which", "agent-browser"],
capture_output=True,
text=True
)
return result.returncode == 0
except:
return False
AGENT_BROWSER_AVAILABLE = check_agent_browser()
app = Flask(__name__)
CORS(app)
# 配置
CAPTURE_DIR = Path(tempfile.gettempdir()) / "web_captures"
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():
"""获取数据库连接(多进程安全:WAL + busy_timeout"""
conn = sqlite3.connect(HISTORY_DB, timeout=15)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=15000")
return conn
def init_db():
"""初始化历史记录表"""
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS captures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT DEFAULT '',
action TEXT NOT NULL,
backend TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'success',
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='',
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, 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()
rid = cur.lastrowid
conn.close()
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"
dest = CAPTURE_DATA_DIR / name
shutil.copy2(src_path, dest)
return str(dest)
def clean_text(raw):
"""清理可读文本:去掉行尾空格、压缩多余空行、去掉首尾空白"""
if not raw:
return ""
lines = [ln.rstrip() for ln in raw.split("\n")]
text = "\n".join(lines)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
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)
class AgentBrowserSession:
"""agent-browser 会话管理器"""
def __init__(self, session_id=None):
self.session_id = session_id or str(uuid.uuid4())[:8]
self.base_cmd = ["agent-browser", "--session", self.session_id]
def run(self, cmd, timeout=60000):
"""执行 agent-browser 命令"""
full_cmd = self.base_cmd + cmd
# 设置必要的环境变量
env = os.environ.copy()
env['AGENT_BROWSER_SOCKET_DIR'] = '/tmp/agent-browser-sockets'
env['AGENT_BROWSER_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
try:
result = subprocess.run(
full_cmd,
capture_output=True,
text=True,
timeout=timeout / 1000,
env=env
)
return result.returncode == 0, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return False, "", "Command timeout"
except Exception as e:
return False, "", str(e)
def open(self, url):
"""打开网页"""
# 添加反爬虫检测的 User-Agent
env = os.environ.copy()
env['AGENT_BROWSER_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
# agent-browser 内部导航等待约 30s 超时,这里留足 45s 让它返回自己的超时报错(避免被 subprocess 硬杀)
return self.run(["open", url], timeout=45000)
def set_viewport(self, width, height):
"""设置视口大小"""
return self.run(["set", "viewport", str(width), str(height)])
def screenshot(self, output_path, full_page=False):
"""截图"""
cmd = ["screenshot", str(output_path)]
if full_page:
cmd.append("--full")
return self.run(cmd, timeout=30000)
def get_html(self):
"""获取HTML"""
success, output, error = self.run(
["eval", "document.documentElement.outerHTML"],
timeout=10000
)
return success, output, error
@staticmethod
def _decode_eval(output):
"""agent-browser eval 结果按 JSON 编码返回,这里解码回原始字符串"""
if output is None:
return ""
out = output.strip()
try:
return json.loads(out)
except Exception:
# 非 JSON(如数字/布尔/纯文本),去掉可能的外层引号
return out.strip('\"\'')
def get_title(self):
"""获取页面标题"""
success, output, error = self.run(
["eval", "document.title"],
timeout=10000
)
if success:
return self._decode_eval(output)
return ""
def get_text(self):
"""提取页面可读文本(干净内容,剔除脚本/样式/标签)"""
success, output, error = self.run(
["eval", "document.body.innerText"],
timeout=30000
)
if success:
return True, self._decode_eval(output), ""
return False, "", error
def scroll_down(self, pixels=800):
"""向下滚动"""
return self.run(["scroll", "down", str(pixels)])
def wait(self, ms):
"""等待"""
return self.run(["wait", str(ms)])
def close(self):
"""关闭浏览器"""
try:
self.run(["close"])
except:
pass
def capture_with_agent_browser(
url: str,
action: str = "screenshot",
scroll_times: int = 0,
scroll_delay: int = 1000,
full_page: bool = False,
viewport: dict = None,
wait_time: int = 2000
):
"""
使用 agent-browser 捕获网页
"""
session = AgentBrowserSession()
temp_file = None
try:
# 设置视口
if viewport:
session.set_viewport(viewport.get("width", 1920), viewport.get("height", 1080))
# 打开网页
success, stdout, stderr = session.open(url)
if not success:
# 检查是否是反爬虫拦截
if "403" in stdout or "Access Denied" in stdout:
return {"success": False, "error": "网站反爬虫拦截 (403),建议使用 Playwright 后端或手动添加请求头"}
if "timed out" in stderr.lower() or "timeout" in stderr.lower():
return {"success": False, "error": "页面加载超时(可能是慢加载/持续加载页面),已自动切换 Playwright 后端重试;若仍失败请增大等待时间或换其他后端"}
return {"success": False, "error": f"Failed to open URL: {stderr}"}
# 等待页面加载
session.wait(wait_time)
# 滚动加载动态内容
if scroll_times > 0:
for i in range(scroll_times):
session.scroll_down(800)
session.wait(scroll_delay)
# 提取页面标题
title = session.get_title()
# 执行操作
if action == "screenshot":
temp_file = CAPTURE_DIR / f"{session.session_id}.png"
success, _, error = session.screenshot(temp_file, full_page=full_page)
if success and temp_file.exists():
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:
# 修复: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:
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}"}
else:
return {"success": False, "title": title, "error": f"Unknown action: {action}"}
except Exception as e:
return {"success": False, "error": str(e)}
finally:
session.close()
async def capture_with_playwright(
url: str,
action: str = "screenshot",
scroll_times: int = 0,
scroll_delay: int = 1000,
full_page: bool = False,
viewport: dict = None,
wait_time: int = 2000
):
"""
使用 Playwright 捕获网页
Playwright 有更强的反爬虫能力
"""
if not PLAYWRIGHT_AVAILABLE:
return {"success": False, "error": "Playwright not installed. Run: pip install playwright && playwright install chromium"}
session_id = str(uuid.uuid4())[:8]
try:
async with async_playwright() as p:
# 启动浏览器,添加反检测配置
# 尝试使用真实 Chrome(如果安装了)
try:
# 首先尝试使用真实 Chrome 浏览器
browser = await p.chromium.launch(
headless=True,
channel='chrome', # 使用真实 Chrome 而不是 Chromium
args=[
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox',
'--ignore-certificate-errors',
]
)
except:
# 如果没有 Chrome,使用 Chromium
browser = await p.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox',
'--ignore-certificate-errors',
]
)
viewport_settings = viewport or {"width": 1920, "height": 1080}
# 创建上下文,模拟真实浏览器
context = await browser.new_context(
viewport={
"width": viewport_settings.get("width", 1920),
"height": viewport_settings.get("height", 1080)
},
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',
# 添加更多浏览器特征
locale='en-US',
timezone_id='America/New_York',
color_scheme='light',
has_touch=False,
is_mobile=False,
java_script_enabled=True,
accept_downloads=True,
)
# 注入反检测脚本
await context.add_init_script("""
// 移除 webdriver 标记
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
// 添加插件
Object.defineProperty(navigator, 'plugins', {
get: () => {
return [
{name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format'},
{name: 'Chrome PDF Viewer', filename: 'mhjfbmdgncjokjjpdkfnmlglggk', description: ''},
{name: 'Native Client', filename: 'internal-nacl-plugin', description: ''}
];
}
});
// 语言列表
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en', 'zh-CN']
});
// 平台
Object.defineProperty(navigator, 'platform', {
get: () => 'Win32'
});
// 硬件并发
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 8
});
// 设备内存
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8
});
// Chrome 对象
window.chrome = {
runtime: {
PlatformOs: {MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd'},
PlatformArch: {ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64'},
PlatformNaclArch: {ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64'},
Request: function() {},
connect: function() {},
sendMessage: function() {}
},
loadTimes: function() {},
csi: function() {},
app: {}
};
""")
page = await context.new_page()
# 应用 stealth 反检测(如果可用)
if STEALTH_AVAILABLE:
await stealth_async(page)
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
except Exception as e:
# 如果 domcontentloaded 超时,尝试 commit
try:
await page.goto(url, wait_until="commit", timeout=30000)
except:
pass
# 固定等待时间(用于验证、加载等过程)
if wait_time > 0:
await page.wait_for_timeout(wait_time)
# 等待页面基本稳定
try:
await page.wait_for_load_state("networkidle", timeout=5000)
except:
# networkidle 超时没关系,继续执行
pass
if scroll_times > 0:
for i in range(scroll_times):
await page.evaluate("window.scrollBy(0, 800)")
await page.wait_for_timeout(scroll_delay)
await page.wait_for_timeout(500)
# 提取页面标题
try:
title = await page.title()
except:
title = ""
result = {}
if action == "screenshot":
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()
result = {"success": True, "title": title, "html": html}
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}"}
await browser.close()
return result
except Exception as e:
return {"success": False, "error": str(e)}
async def capture_with_cdp(
url_hint: str = "",
action: str = "screenshot",
cdp_port: int = 9222
):
"""
使用 CDP 连接到已打开的 Chrome 浏览器
用于方案三:手动验证后自动截图
"""
if not PLAYWRIGHT_AVAILABLE:
return {"success": False, "error": "Playwright not installed"}
try:
async with async_playwright() as p:
# 连接到已运行的 Chrome
browser = await p.chromium.connect_over_cdp(f"http://localhost:{cdp_port}")
# 获取所有页面
contexts = browser.contexts
result_data = []
for context in contexts:
for page in context.pages:
current_url = page.url
title = await page.title()
# 如果 URL 包含提示词或者是唯一页面
if url_hint in current_url or not url_hint:
if action == "screenshot":
temp_file = CAPTURE_DIR / f"cdp_capture_{uuid.uuid4()[:8]}.png"
await page.screenshot(path=str(temp_file), full_page=True)
result_data.append({
"url": current_url,
"title": title,
"file_path": str(temp_file)
})
elif action == "html":
html = await page.content()
result_data.append({
"url": current_url,
"title": title,
"html": html
})
elif action == "text":
text = await page.evaluate("document.body.innerText")
result_data.append({
"url": current_url,
"title": title,
"text": clean_text(text)
})
if result_data:
return {"success": True, "pages": result_data}
else:
return {"success": False, "error": "No matching page found"}
except Exception as e:
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:
if "timed out" in err.lower() or "timeout" in err.lower():
return {"success": False, "error": "页面加载超时(可能是慢加载/持续加载页面),已自动切换 Playwright 后端重试;若仍失败请增大等待时间或换其他后端"}
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=60000)
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":
result = smart_capture_agent_browser(url, cfg, wait_time, viewport)
# 打开页面超时 → 自动切 Playwright 重试(domcontentloaded 更宽容)
if (not result.get("success")
and "自动切换 Playwright" in (result.get("error") or "")
and PLAYWRIGHT_AVAILABLE):
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()
except Exception as e:
return {"success": False, "error": str(e)}
return result
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",
scroll_times: int = 0,
scroll_delay: int = 1000,
full_page: bool = False,
viewport: dict = None,
wait_time: int = 2000,
backend: str = "auto",
cdp_port: int = 9222
):
"""
捕获网页
Args:
backend: "auto", "agent-browser", "playwright", "chrome-cdp"
"""
# 选择后端
if backend == "auto":
if AGENT_BROWSER_AVAILABLE:
backend = "agent-browser"
elif PLAYWRIGHT_AVAILABLE:
backend = "playwright"
else:
return {"success": False, "error": "No browser backend available. Install agent-browser or playwright."}
# 使用对应后端
if backend == "agent-browser":
if not AGENT_BROWSER_AVAILABLE:
return {"success": False, "error": "agent-browser not available"}
result = capture_with_agent_browser(
url, action, scroll_times, scroll_delay, full_page, viewport, wait_time
)
result["backend"] = "agent-browser"
# agent-browser 打开页面超时(慢加载/持续加载页)→ 自动切 Playwright 重试(domcontentloaded 更宽容)
if (not result.get("success")
and "自动切换 Playwright" in (result.get("error") or "")
and PLAYWRIGHT_AVAILABLE):
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
capture_with_playwright(
url, action, scroll_times, scroll_delay, full_page, viewport, wait_time
)
)
loop.close()
result["backend"] = "playwright"
except Exception as e:
return {"success": False, "error": str(e)}
return result
elif backend == "playwright":
if not PLAYWRIGHT_AVAILABLE:
return {"success": False, "error": "Playwright not available"}
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
capture_with_playwright(
url, action, scroll_times, scroll_delay, full_page, viewport, wait_time
)
)
loop.close()
result["backend"] = "playwright"
return result
except Exception as e:
return {"success": False, "error": str(e)}
elif backend == "chrome-cdp":
# 连接已打开的 Chrome(需 --remote-debugging-port=<cdp_port>),归一化为单结果
if not PLAYWRIGHT_AVAILABLE:
return {"success": False, "error": "Playwright not installed (chrome-cdp 依赖 playwright)"}
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
r = loop.run_until_complete(
capture_with_cdp(url_hint=url, action=action, cdp_port=cdp_port)
)
loop.close()
except Exception as e:
return {"success": False, "error": str(e)}
if not r.get("success"):
return r
if not r.get("pages"):
return {"success": False, "error": "chrome-cdp 未找到匹配页面(请确认 Chrome 已开 --remote-debugging-port"}
p0 = r["pages"][0]
res = {"success": True, "title": p0.get("title", ""), "backend": "chrome-cdp"}
if action == "screenshot":
res["file_path"] = p0["file_path"]
elif action == "html":
res["html"] = p0["html"]
elif action == "text":
res["text"] = p0["text"]
return res
else:
return {"success": False, "error": f"Unknown backend: {backend}"}
@app.route('/')
def index():
"""主页"""
return render_template('index.html')
@app.route('/api/backends')
def api_backends():
"""返回可用后端及默认选择顺序,供前端渲染选择器"""
available = []
if AGENT_BROWSER_AVAILABLE:
available.append("agent-browser")
if PLAYWRIGHT_AVAILABLE:
available.append("playwright")
# chrome-cdp 依赖 playwright 的 connect_over_cdp
available.append("chrome-cdp")
unavailable = []
if not AGENT_BROWSER_AVAILABLE:
unavailable.append("agent-browser")
if not PLAYWRIGHT_AVAILABLE:
unavailable.append("playwright")
unavailable.append("chrome-cdp")
return jsonify({
"success": True,
"default": "auto",
"available": available,
"unavailable": unavailable,
"priority": available + unavailable,
"labels": {
"auto": "自动(推荐:优先 agent-browser,打开超时自动回退 Playwright",
"agent-browser": "agent-browserRust 版,反爬强、快,默认首选)",
"playwright": "PlaywrightPython 版,domcontentloaded 更宽容,慢页面更稳)",
"chrome-cdp": "Chrome CDP(连接已打开的 Chrome,需 --remote-debugging-port 启动)"
}
})
@app.route('/api')
def api_info():
"""API信息"""
return jsonify({
"service": "Web Capture API",
"version": "2.1.0",
"backends": {
"agent-browser": "available" if AGENT_BROWSER_AVAILABLE else "unavailable",
"playwright": "available" if PLAYWRIGHT_AVAILABLE else "unavailable"
},
"endpoints": {
"/api/capture": "POST - Capture webpage (screenshot/html/text/smart) + 自动入库历史(记录调用者/方式/原始HTML)",
"/api/backends": "GET - 可用后端列表与默认顺序(供前端渲染后端选择器)",
"/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"
}
})
@app.route('/api/history', methods=['GET'])
def history_list():
"""
历史记录分页列表
参数: page(默认1), page_size(默认15, 最大100), action(筛选: all/screenshot/html/text), search(按url/title模糊搜索)
"""
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
action = request.args.get('action', 'all')
search = request.args.get('search', '').strip()
where = []
params = []
if action and action != 'all':
where.append("action = ?")
params.append(action)
if search:
where.append("(url LIKE ? OR title LIKE ?)")
params.append(f"%{search}%")
params.append(f"%{search}%")
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
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, 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]
).fetchall()
conn.close()
records = [dict(r) for r in rows]
return jsonify({
"success": True,
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size if total else 1,
"records": records
})
@app.route('/api/history/<int:rid>', methods=['GET'])
def history_detail(rid):
"""历史记录详情(含完整内容)"""
conn = get_db()
r = conn.execute("SELECT * FROM captures WHERE id=?", (rid,)).fetchone()
conn.close()
if not r:
return jsonify({"success": False, "error": "记录不存在"}), 404
return jsonify({"success": True, "record": dict(r)})
@app.route('/api/history/<int:rid>/file', methods=['GET'])
def history_file(rid):
"""读取历史截图文件"""
conn = get_db()
r = conn.execute("SELECT file_path FROM captures WHERE id=?", (rid,)).fetchone()
conn.close()
if not r or not r["file_path"] or not Path(r["file_path"]).exists():
return jsonify({"success": False, "error": "文件不存在或已删除"}), 404
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, html_path FROM captures WHERE id=?", (rid,)).fetchone()
if r:
conn.execute("DELETE FROM captures WHERE id=?", (rid,))
conn.commit()
conn.close()
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})
@app.route('/health')
def health():
"""健康检查"""
status = "healthy" if (AGENT_BROWSER_AVAILABLE or PLAYWRIGHT_AVAILABLE) else "degraded"
return jsonify({
"status": status,
"agent-browser": "installed" if AGENT_BROWSER_AVAILABLE else "not found",
"playwright": "installed" if PLAYWRIGHT_AVAILABLE else "not found",
"timestamp": datetime.now().isoformat()
})
@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():
"""
捕获网页接口
请求体:
{
"url": "https://example.com",
"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
"smart_config": {...} // 可选,按需截图时临时覆盖 LLM 配置
}
"""
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No JSON data provided"}), 400
url = data.get("url")
if not url:
return jsonify({"success": False, "error": "URL is required"}), 400
# 添加协议前缀
if not url.startswith(("http://", "https://")):
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))
viewport = data.get("viewport")
wait_time = int(data.get("wait_time", 2000))
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,
scroll_times=scroll_times,
scroll_delay=scroll_delay,
full_page=full_page,
viewport=viewport,
wait_time=wait_time,
backend=backend,
cdp_port=cdp_port
)
backend_used = result.get("backend", backend)
title = result.get("title", "")
if not result["success"]:
save_capture(url, title, action, backend_used, "failed", error=result.get("error", ""),
caller=caller, call_method=call_method)
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,
html_path=html_path, caller=caller, call_method=call_method)
return send_file(persisted, mimetype='image/png')
elif action == "html":
save_capture(url, title, action, backend_used, "success", content=result["html"],
html_path=html_path, caller=caller, call_method=call_method)
return jsonify(result)
elif action == "text":
save_capture(url, title, action, backend_used, "success", content=result["text"],
html_path=html_path, caller=caller, call_method=call_method)
return jsonify(result)
if __name__ == '__main__':
print("🌐 Web Capture API Starting...")
print(f"📁 Capture directory: {CAPTURE_DIR}")
print(f"🔧 Backends:")
print(f" - agent-browser: {'✅' if AGENT_BROWSER_AVAILABLE else '❌'}")
print(f" - playwright: {'✅' if PLAYWRIGHT_AVAILABLE else '❌'}")
if not AGENT_BROWSER_AVAILABLE and not PLAYWRIGHT_AVAILABLE:
print("\n⚠️ 没有可用的浏览器后端,请安装其中一个:")
print(" agent-browser: npm install -g agent-browser && agent-browser install")
print(" playwright: pip install playwright && playwright install chromium")
print("\n🚀 Server running on http://0.0.0.0:16025")
app.run(host='0.0.0.0', port=16025, debug=True)