193 lines
5.7 KiB
Python
193 lines
5.7 KiB
Python
"""浏览器会话管理:基于 skill-template 的 simulator_browser_rpa 示例。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import random
|
||
from typing import Any, List, Optional, Tuple
|
||
|
||
from jiangchang_skill_core import config
|
||
from jiangchang_skill_core.activity import interruptible_sleep
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DEFAULT_PAGE_TIMEOUT_MS = 15_000
|
||
|
||
CHROME_LAUNCH_ARGS: List[str] = [
|
||
"--start-maximized",
|
||
"--disable-blink-features=AutomationControlled",
|
||
]
|
||
|
||
STEALTH_INIT_SCRIPT = """
|
||
(() => {
|
||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||
})();
|
||
"""
|
||
|
||
|
||
def chrome_launch_args() -> List[str]:
|
||
return list(CHROME_LAUNCH_ARGS)
|
||
|
||
|
||
def _headless_from_env() -> bool:
|
||
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||
return v in ("1", "true", "yes", "on")
|
||
|
||
|
||
def find_browser_executable() -> Optional[str]:
|
||
"""查找系统 Chrome 或 Edge 浏览器。"""
|
||
try:
|
||
from jiangchang_skill_core.runtime_env import find_chrome_executable as _find
|
||
chrome = _find()
|
||
if chrome and os.path.isfile(chrome):
|
||
return chrome
|
||
except ImportError:
|
||
pass
|
||
|
||
candidates = [
|
||
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
|
||
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
|
||
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
|
||
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
|
||
]
|
||
for path in candidates:
|
||
if path and os.path.isfile(path):
|
||
return path
|
||
return None
|
||
|
||
|
||
# 向后兼容别名
|
||
find_edge_executable = find_browser_executable
|
||
|
||
|
||
def _clear_node_options() -> None:
|
||
os.environ.pop("NODE_OPTIONS", None)
|
||
|
||
|
||
async def start_browser_session(
|
||
profile_dir: str,
|
||
headless: bool = False,
|
||
) -> Tuple[Any, Any, Any]:
|
||
"""启动持久化 Chrome/Edge 浏览器会话。
|
||
|
||
URL 不在此文件打开;由 invoice_rpa.py 负责 page.goto。
|
||
|
||
Args:
|
||
profile_dir: 浏览器配置目录
|
||
headless: 是否无头模式(生产必须为 False)
|
||
|
||
Returns:
|
||
(playwright, context, page) 三元组
|
||
|
||
Raises:
|
||
RuntimeError: 浏览器启动失败
|
||
"""
|
||
if not profile_dir:
|
||
raise RuntimeError("ERROR:MISSING_BROWSER 缺少浏览器配置目录")
|
||
|
||
# 确保有头模式(生产要求)
|
||
if headless:
|
||
logger.warning("生产环境强制使用有头模式")
|
||
headless = False
|
||
|
||
# 确保 profile 目录存在
|
||
os.makedirs(profile_dir, exist_ok=True)
|
||
|
||
_clear_node_options()
|
||
hl = headless if headless is not None else _headless_from_env()
|
||
chrome = find_browser_executable()
|
||
if not chrome:
|
||
raise RuntimeError(
|
||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
|
||
)
|
||
|
||
args = chrome_launch_args()
|
||
logger.info(
|
||
"browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
|
||
chrome,
|
||
profile_dir,
|
||
args,
|
||
hl,
|
||
)
|
||
|
||
try:
|
||
from playwright.async_api import async_playwright
|
||
|
||
pw = await async_playwright().start()
|
||
context = None
|
||
try:
|
||
context = await pw.chromium.launch_persistent_context(
|
||
user_data_dir=profile_dir,
|
||
headless=hl,
|
||
executable_path=chrome,
|
||
locale="zh-CN",
|
||
no_viewport=True,
|
||
args=args,
|
||
ignore_default_args=["--enable-automation"],
|
||
)
|
||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||
|
||
page = context.pages[0] if context.pages else await context.new_page()
|
||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||
logger.info("browser_started profile_dir=%s", profile_dir)
|
||
return pw, context, page
|
||
except Exception:
|
||
if context is not None:
|
||
try:
|
||
await context.close()
|
||
except Exception:
|
||
pass
|
||
await pw.stop()
|
||
raise
|
||
except RuntimeError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("browser_start_failed error=%s", str(e))
|
||
raise RuntimeError(f"ERROR:MISSING_BROWSER 浏览器启动失败: {str(e)}")
|
||
|
||
|
||
async def close_browser_context(pw: Any, context: Any) -> None:
|
||
"""安全关闭浏览器上下文。"""
|
||
try:
|
||
if context is not None:
|
||
await context.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if pw is not None:
|
||
await pw.stop()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def wait_for_page_ready(page: Any, timeout_sec: float = 30.0) -> bool:
|
||
"""等待页面加载完成。"""
|
||
async def _check_ready():
|
||
try:
|
||
ready_state = await page.evaluate("document.readyState")
|
||
if ready_state == "complete":
|
||
return True
|
||
loading_selectors = [".loading", ".spinner", "[data-loading]"]
|
||
for sel in loading_selectors:
|
||
if await page.locator(sel).count() > 0:
|
||
return False
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
try:
|
||
from jiangchang_skill_core.rpa import poll_until
|
||
result = await poll_until(_check_ready, timeout_sec=timeout_sec, interval_sec=0.5)
|
||
return result.ok
|
||
except ImportError:
|
||
# fallback: 简单等待
|
||
import asyncio
|
||
await asyncio.sleep(min(timeout_sec, 3.0))
|
||
return True
|
||
|
||
|
||
async def random_delay(min_sec: float = 1.0, max_sec: float = 3.0) -> None:
|
||
"""随机延迟,模拟人类操作节奏。"""
|
||
delay = random.uniform(min_sec, max_sec)
|
||
await interruptible_sleep(delay) |