完善模板,加入RPA标准
This commit is contained in:
@@ -1 +1,16 @@
|
||||
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync.
|
||||
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging + config + rpa in sync.
|
||||
from .config import ensure_env_file, get, get_bool, get_float, get_int
|
||||
|
||||
try:
|
||||
from . import rpa
|
||||
except ImportError:
|
||||
rpa = None # type: ignore[assignment,misc]
|
||||
|
||||
__all__ = [
|
||||
"ensure_env_file",
|
||||
"get",
|
||||
"get_bool",
|
||||
"get_float",
|
||||
"get_int",
|
||||
"rpa",
|
||||
]
|
||||
|
||||
126
scripts/jiangchang_skill_core/config.py
Normal file
126
scripts/jiangchang_skill_core/config.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""三级优先级配置读取 + 首次 .env 落盘(进程 env > 数据目录 .env > .env.example)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from .runtime_env import get_data_root, get_user_id
|
||||
|
||||
_skill_slug: str | None = None
|
||||
_example_path: str | None = None
|
||||
_env_file_path: str | None = None
|
||||
_user_env_cache: dict[str, str] | None = None
|
||||
_example_defaults_cache: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _parse_env_file(path: str) -> dict[str, str]:
|
||||
"""标准库手写 .env 解析(不引 python-dotenv)。"""
|
||||
result: dict[str, str] = {}
|
||||
if not path or not os.path.isfile(path):
|
||||
return result
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not key:
|
||||
continue
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||
value = value[1:-1]
|
||||
elif " #" in value:
|
||||
value = value.split(" #", 1)[0].strip()
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _get_user_env() -> dict[str, str]:
|
||||
global _user_env_cache
|
||||
if _user_env_cache is not None:
|
||||
return _user_env_cache
|
||||
if _env_file_path and os.path.isfile(_env_file_path):
|
||||
_user_env_cache = _parse_env_file(_env_file_path)
|
||||
else:
|
||||
_user_env_cache = {}
|
||||
return _user_env_cache
|
||||
|
||||
|
||||
def _get_example_defaults() -> dict[str, str]:
|
||||
global _example_defaults_cache
|
||||
if _example_defaults_cache is not None:
|
||||
return _example_defaults_cache
|
||||
if _example_path and os.path.isfile(_example_path):
|
||||
_example_defaults_cache = _parse_env_file(_example_path)
|
||||
else:
|
||||
_example_defaults_cache = {}
|
||||
return _example_defaults_cache
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""测试用:清空解析缓存。"""
|
||||
global _user_env_cache, _example_defaults_cache
|
||||
_user_env_cache = None
|
||||
_example_defaults_cache = None
|
||||
|
||||
|
||||
def ensure_env_file(skill_slug: str, example_path: str) -> str:
|
||||
"""首次把 .env.example copy 到 {data_root}/{user}/{slug}/.env(已存在不覆盖),返回路径。"""
|
||||
global _skill_slug, _example_path, _env_file_path
|
||||
_skill_slug = skill_slug
|
||||
_example_path = os.path.abspath(example_path)
|
||||
dest_dir = os.path.join(get_data_root(), get_user_id(), skill_slug)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
dest = os.path.join(dest_dir, ".env")
|
||||
_env_file_path = dest
|
||||
if not os.path.isfile(dest) and os.path.isfile(_example_path):
|
||||
shutil.copy2(_example_path, dest)
|
||||
reset_cache()
|
||||
return dest
|
||||
|
||||
|
||||
def get(key: str, default: Any = None) -> str | None:
|
||||
"""进程环境变量 > 数据目录 .env > .env.example 默认值。"""
|
||||
env_val = os.environ.get(key)
|
||||
if env_val is not None and env_val != "":
|
||||
return env_val
|
||||
user_val = _get_user_env().get(key)
|
||||
if user_val is not None and user_val != "":
|
||||
return user_val
|
||||
example_val = _get_example_defaults().get(key)
|
||||
if example_val is not None and example_val != "":
|
||||
return example_val
|
||||
return default
|
||||
|
||||
|
||||
def get_bool(key: str, default: bool = False) -> bool:
|
||||
val = get(key)
|
||||
if val is None:
|
||||
return default
|
||||
return str(val).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def get_float(key: str, default: float) -> float:
|
||||
val = get(key)
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_int(key: str, default: int) -> int:
|
||||
val = get(key)
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
60
scripts/jiangchang_skill_core/rpa/__init__.py
Normal file
60
scripts/jiangchang_skill_core/rpa/__init__.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""浏览器 RPA 共享库:stealth、拟人操作、启动封装、HITL 等待、失败存证。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import anti_detect, artifacts, errors, human_login, stealth
|
||||
|
||||
try:
|
||||
from .browser import launch_persistent_browser
|
||||
except ImportError:
|
||||
launch_persistent_browser = None # type: ignore[misc, assignment]
|
||||
|
||||
__all__ = [
|
||||
"anti_detect",
|
||||
"artifacts",
|
||||
"errors",
|
||||
"human_login",
|
||||
"stealth",
|
||||
"launch_persistent_browser",
|
||||
# re-export common anti_detect helpers
|
||||
"random_delay",
|
||||
"human_delay_short",
|
||||
"human_delay_page",
|
||||
"human_delay_batch",
|
||||
"human_mouse_move",
|
||||
"human_mouse_wiggle",
|
||||
"human_click",
|
||||
"human_type",
|
||||
"human_scroll",
|
||||
# human_login
|
||||
"wait_for_captcha_pass",
|
||||
"wait_for_login",
|
||||
"DEFAULT_CAPTCHA_MARKERS",
|
||||
# artifacts
|
||||
"artifact_path",
|
||||
"capture_failure",
|
||||
# stealth
|
||||
"STEALTH_INIT_SCRIPT",
|
||||
"stealth_enabled",
|
||||
"persistent_context_launch_parts",
|
||||
]
|
||||
|
||||
from .anti_detect import (
|
||||
human_click,
|
||||
human_delay_batch,
|
||||
human_delay_page,
|
||||
human_delay_short,
|
||||
human_mouse_move,
|
||||
human_mouse_wiggle,
|
||||
human_scroll,
|
||||
human_type,
|
||||
random_delay,
|
||||
)
|
||||
from .artifacts import artifact_path, capture_failure
|
||||
from .human_login import DEFAULT_CAPTCHA_MARKERS, wait_for_captcha_pass, wait_for_login
|
||||
from .stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
stealth_enabled,
|
||||
)
|
||||
216
scripts/jiangchang_skill_core/rpa/anti_detect.py
Normal file
216
scripts/jiangchang_skill_core/rpa/anti_detect.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""防反爬:随机延迟、人工鼠标轨迹模拟、拟人滚动。
|
||||
|
||||
所有延迟使用 random.uniform() + asyncio.sleep()。
|
||||
鼠标移动使用三次贝塞尔曲线 + 随机抖动,避免机械直线轨迹。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── 随机延迟 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def random_delay(min_s: float, max_s: float) -> float:
|
||||
"""随机延迟 min_s ~ max_s 秒,返回实际延迟秒数。"""
|
||||
delay = random.uniform(min_s, max_s)
|
||||
await asyncio.sleep(delay)
|
||||
return delay
|
||||
|
||||
|
||||
async def human_delay_short() -> float:
|
||||
"""页面内操作间短延迟 0.5~2 秒(点击/滚动)。"""
|
||||
return await random_delay(0.5, 2.0)
|
||||
|
||||
|
||||
async def human_delay_page(min_s: float = 2, max_s: float = 8) -> float:
|
||||
"""页面间/店铺间切换延迟(可配置范围)。"""
|
||||
return await random_delay(min_s, max_s)
|
||||
|
||||
|
||||
async def human_delay_batch(min_s: float = 5, max_s: float = 15) -> float:
|
||||
"""批次间/翻页间长延迟。"""
|
||||
return await random_delay(min_s, max_s)
|
||||
|
||||
|
||||
# ── 鼠标轨迹 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def human_mouse_move(
|
||||
page,
|
||||
target_x: float,
|
||||
target_y: float,
|
||||
steps: Optional[int] = None,
|
||||
) -> None:
|
||||
"""模拟人工鼠标移动到目标坐标。
|
||||
|
||||
使用三次贝塞尔曲线生成 S/C 形路径,中间控制点随机偏移,
|
||||
每步添加微小随机抖动,避免完美直线移动。
|
||||
|
||||
Args:
|
||||
page: Playwright Page 对象。
|
||||
target_x: 目标 X 坐标。
|
||||
target_y: 目标 Y 坐标。
|
||||
steps: 移动步数(None 时按距离自动计算)。
|
||||
"""
|
||||
if steps is None:
|
||||
dist = math.hypot(target_x, target_y)
|
||||
steps = max(8, int(dist / 15))
|
||||
|
||||
# 随机起始点(模拟鼠标从页面其他位置移动过来)
|
||||
start_x = target_x + random.uniform(-300, 300)
|
||||
start_y = target_y + random.uniform(-200, 200)
|
||||
|
||||
# 两个贝塞尔控制点(制造弯曲路径)
|
||||
dx = target_x - start_x
|
||||
dy = target_y - start_y
|
||||
|
||||
cp1_x = start_x + dx * random.uniform(0.2, 0.5) + random.uniform(-120, 120)
|
||||
cp1_y = start_y + dy * random.uniform(0.1, 0.4) + random.uniform(-100, 100)
|
||||
cp2_x = start_x + dx * random.uniform(0.5, 0.8) + random.uniform(-100, 100)
|
||||
cp2_y = start_y + dy * random.uniform(0.4, 0.7) + random.uniform(-80, 80)
|
||||
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
# 三次贝塞尔: B(t) = (1-t)³·P0 + 3(1-t)²·t·P1 + 3(1-t)·t²·P2 + t³·P3
|
||||
u = 1 - t
|
||||
x = (
|
||||
(u ** 3) * start_x
|
||||
+ 3 * (u ** 2) * t * cp1_x
|
||||
+ 3 * u * (t ** 2) * cp2_x
|
||||
+ (t ** 3) * target_x
|
||||
)
|
||||
y = (
|
||||
(u ** 3) * start_y
|
||||
+ 3 * (u ** 2) * t * cp1_y
|
||||
+ 3 * u * (t ** 2) * cp2_y
|
||||
+ (t ** 3) * target_y
|
||||
)
|
||||
|
||||
# 微小随机抖动
|
||||
x += random.uniform(-1.5, 1.5)
|
||||
y += random.uniform(-1.5, 1.5)
|
||||
|
||||
await page.mouse.move(x, y)
|
||||
await asyncio.sleep(random.uniform(0.003, 0.015))
|
||||
|
||||
# 最终精确到位
|
||||
await page.mouse.move(target_x, target_y)
|
||||
|
||||
|
||||
async def human_click(
|
||||
page,
|
||||
selector: Optional[str] = None,
|
||||
*,
|
||||
x: Optional[float] = None,
|
||||
y: Optional[float] = None,
|
||||
) -> None:
|
||||
"""拟人点击:贝塞尔轨迹移动 → 微延迟 → 点击。
|
||||
|
||||
支持两种定位方式:
|
||||
- selector: CSS 选择器,自动取元素中心附近随机偏移
|
||||
- x, y: 直接指定坐标(自动加微小随机偏移)
|
||||
|
||||
Args:
|
||||
page: Playwright Page 对象。
|
||||
selector: CSS 选择器(与 x/y 二选一)。
|
||||
x: 目标 X 坐标。
|
||||
y: 目标 Y 坐标。
|
||||
"""
|
||||
if selector:
|
||||
locator = page.locator(selector).first
|
||||
box = await locator.bounding_box()
|
||||
if box is None:
|
||||
raise ValueError(f"无法获取元素边界: {selector}")
|
||||
target_x = box["x"] + box["width"] * random.uniform(0.3, 0.7)
|
||||
target_y = box["y"] + box["height"] * random.uniform(0.3, 0.7)
|
||||
elif x is not None and y is not None:
|
||||
target_x = x + random.uniform(-3, 3)
|
||||
target_y = y + random.uniform(-3, 3)
|
||||
else:
|
||||
raise ValueError("必须提供 selector 或 (x, y) 坐标")
|
||||
|
||||
await human_mouse_move(page, target_x, target_y)
|
||||
await human_delay_short()
|
||||
await page.mouse.click(target_x, target_y)
|
||||
|
||||
|
||||
# ── 滚动模拟 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def human_scroll(
|
||||
page, direction: str = "down", amount: Optional[int] = None
|
||||
) -> None:
|
||||
"""模拟人工滚动(分段 + 变速 + 微延迟)。
|
||||
|
||||
Args:
|
||||
page: Playwright Page 对象。
|
||||
direction: "up" 或 "down"。
|
||||
amount: 滚动像素总量,默认随机 300~800。
|
||||
"""
|
||||
if amount is None:
|
||||
amount = random.randint(300, 800)
|
||||
|
||||
sign = -1 if direction == "up" else 1
|
||||
remaining = amount
|
||||
step_count = random.randint(3, 7)
|
||||
|
||||
for i in range(step_count):
|
||||
step = remaining // (step_count - i) + random.randint(-30, 30)
|
||||
step = max(10, min(step, remaining))
|
||||
remaining -= step
|
||||
|
||||
await page.mouse.wheel(0, sign * step)
|
||||
await asyncio.sleep(random.uniform(0.05, 0.2))
|
||||
|
||||
if remaining > 0:
|
||||
await page.mouse.wheel(0, sign * remaining)
|
||||
|
||||
await human_delay_short()
|
||||
|
||||
|
||||
# ── 进场鼠标随机晃动 ────────────────────────────────────────
|
||||
|
||||
|
||||
async def human_mouse_wiggle(page) -> None:
|
||||
"""页面进入后随机移动鼠标 2~4 次(贝塞尔轨迹),模拟真人到场环顾。"""
|
||||
try:
|
||||
vp = page.viewport_size or {"width": 1280, "height": 800}
|
||||
except Exception:
|
||||
vp = {"width": 1280, "height": 800}
|
||||
w, h = vp.get("width", 1280), vp.get("height", 800)
|
||||
for _ in range(random.randint(2, 4)):
|
||||
tx = random.uniform(w * 0.2, w * 0.8)
|
||||
ty = random.uniform(h * 0.2, h * 0.7)
|
||||
await human_mouse_move(page, tx, ty)
|
||||
await asyncio.sleep(random.uniform(0.2, 0.8))
|
||||
|
||||
|
||||
# ── 拟人逐字输入 ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def human_type(page, locator, text: str) -> None:
|
||||
"""真人式输入:贝塞尔移动到输入框 → 真实点击聚焦 → 逐字符 keyboard.type。
|
||||
|
||||
全程使用 isTrusted=true 的可信事件,绝不用 JS 设置 value。
|
||||
"""
|
||||
box = await locator.bounding_box()
|
||||
if box is None:
|
||||
raise ValueError("human_type: 无法获取输入框边界")
|
||||
tx = box["x"] + box["width"] * random.uniform(0.3, 0.7)
|
||||
ty = box["y"] + box["height"] * random.uniform(0.3, 0.7)
|
||||
await human_mouse_move(page, tx, ty)
|
||||
await asyncio.sleep(random.uniform(0.15, 0.5))
|
||||
await page.mouse.click(tx, ty)
|
||||
await asyncio.sleep(random.uniform(0.2, 0.6))
|
||||
# 清空已有内容(用键盘,不用 JS)
|
||||
await page.keyboard.press("Control+A")
|
||||
await page.keyboard.press("Delete")
|
||||
await asyncio.sleep(random.uniform(0.2, 0.5))
|
||||
for ch in text:
|
||||
await page.keyboard.type(ch, delay=random.uniform(90, 240))
|
||||
29
scripts/jiangchang_skill_core/rpa/artifacts.py
Normal file
29
scripts/jiangchang_skill_core/rpa/artifacts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""RPA 失败存证路径与截图。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _artifacts_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:
|
||||
"""返回 {data_dir}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.{ext}"""
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
directory = os.path.join(data_dir, "rpa-artifacts", batch_id)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
return os.path.join(directory, f"{tag}_{ts}.{ext}")
|
||||
|
||||
|
||||
async def capture_failure(page, data_dir: str, batch_id: str, tag: str) -> str:
|
||||
"""受 OPENCLAW_ARTIFACTS_ON_FAILURE 控制,截图并返回路径;禁用时返回空字符串。"""
|
||||
if not _artifacts_enabled():
|
||||
return ""
|
||||
path = artifact_path(data_dir, batch_id, tag, ext="png")
|
||||
await page.screenshot(path=path, full_page=True)
|
||||
return path
|
||||
62
scripts/jiangchang_skill_core/rpa/browser.py
Normal file
62
scripts/jiangchang_skill_core/rpa/browser.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""统一 persistent context 启动封装。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..runtime_env import find_chrome_executable
|
||||
from .stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
stealth_enabled,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import BrowserContext
|
||||
|
||||
|
||||
async def launch_persistent_browser(
|
||||
playwright,
|
||||
*,
|
||||
profile_dir: str,
|
||||
channel: str = "chrome",
|
||||
executable_path: str | None = None,
|
||||
headless: bool | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
record_video_dir: str | None = None,
|
||||
) -> "BrowserContext":
|
||||
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
||||
|
||||
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
||||
record_video_dir 非空则开录屏。
|
||||
"""
|
||||
if headless is None:
|
||||
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
headless = v in ("1", "true", "yes", "on")
|
||||
|
||||
chrome = executable_path or find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
||||
)
|
||||
|
||||
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
|
||||
launch_kwargs: dict = dict(
|
||||
user_data_dir=profile_dir,
|
||||
headless=headless,
|
||||
executable_path=chrome,
|
||||
locale="zh-CN",
|
||||
no_viewport=True,
|
||||
args=args,
|
||||
)
|
||||
if ignore is not None:
|
||||
launch_kwargs["ignore_default_args"] = ignore
|
||||
if record_video_dir:
|
||||
launch_kwargs["record_video_dir"] = record_video_dir
|
||||
|
||||
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
|
||||
if stealth_enabled():
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
return context
|
||||
20
scripts/jiangchang_skill_core/rpa/errors.py
Normal file
20
scripts/jiangchang_skill_core/rpa/errors.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""RPA 场景统一错误码(stdout / 异常消息前缀)。"""
|
||||
|
||||
ERR_REQUIRE_LOGIN = "ERROR:REQUIRE_LOGIN"
|
||||
ERR_LOGIN_TIMEOUT = "ERROR:LOGIN_TIMEOUT"
|
||||
ERR_CAPTCHA_NEED_HUMAN = "ERROR:CAPTCHA_NEED_HUMAN"
|
||||
ERR_RATE_LIMITED = "ERROR:RATE_LIMITED"
|
||||
ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
|
||||
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
|
||||
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
|
||||
|
||||
__all__ = [
|
||||
"ERR_REQUIRE_LOGIN",
|
||||
"ERR_LOGIN_TIMEOUT",
|
||||
"ERR_CAPTCHA_NEED_HUMAN",
|
||||
"ERR_RATE_LIMITED",
|
||||
"ERR_MISSING_BROWSER",
|
||||
"ERR_DEVICE_NOT_READY",
|
||||
"ERR_WINDOW_NOT_FOUND",
|
||||
]
|
||||
97
scripts/jiangchang_skill_core/rpa/human_login.py
Normal file
97
scripts/jiangchang_skill_core/rpa/human_login.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""验证码/登录 HITL 等待(站点无关通用部分)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
DEFAULT_CAPTCHA_MARKERS = (
|
||||
"punish",
|
||||
"baxia",
|
||||
"nc_login",
|
||||
"tmd",
|
||||
"x5sec",
|
||||
"安全验证",
|
||||
"请按住滑块",
|
||||
"拖动到最右边",
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_captcha_pass(
|
||||
page,
|
||||
captcha_markers: tuple[str, ...] = DEFAULT_CAPTCHA_MARKERS,
|
||||
timeout_sec: int = 120,
|
||||
) -> bool:
|
||||
"""
|
||||
检测到验证码/拦截页时,暂停等待用户手工完成验证。
|
||||
每 3 秒轮询一次页面状态,直到页面不再是拦截状态或超时。
|
||||
|
||||
Returns:
|
||||
True — 验证码已通过,可以继续
|
||||
False — 超时,用户未在规定时间内完成验证
|
||||
"""
|
||||
print(
|
||||
f"[验证码] 检测到安全拦截,请在浏览器中手工完成验证。"
|
||||
f"等待最多 {timeout_sec} 秒..."
|
||||
)
|
||||
|
||||
elapsed = 0
|
||||
while elapsed < timeout_sec:
|
||||
await asyncio.sleep(3)
|
||||
elapsed += 3
|
||||
try:
|
||||
url = page.url or ""
|
||||
text = ""
|
||||
try:
|
||||
text = await page.locator("body").inner_text(timeout=2_000)
|
||||
except Exception:
|
||||
pass
|
||||
haystack = (url + " " + text[:1000]).lower()
|
||||
still_blocked = any(m.lower() in haystack for m in captcha_markers)
|
||||
if not still_blocked:
|
||||
print(f"[验证码] 已通过!({elapsed}s 后检测到正常页面)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[验证码] 超时 {timeout_sec}s,用户未完成验证")
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_login(
|
||||
page,
|
||||
success_selectors: list[str],
|
||||
timeout_sec: int = 180,
|
||||
) -> bool:
|
||||
"""
|
||||
轮询等待登录成功标志出现(站点专属选择器由调用方传入)。
|
||||
|
||||
Args:
|
||||
page: Playwright Page 对象。
|
||||
success_selectors: 登录成功后应出现的 CSS 选择器列表。
|
||||
timeout_sec: 最长等待秒数。
|
||||
|
||||
Returns:
|
||||
True — 检测到登录成功标志
|
||||
False — 超时
|
||||
"""
|
||||
print(
|
||||
f"[登录] 请在浏览器中完成登录。等待最多 {timeout_sec} 秒,"
|
||||
"期间程序不会操作页面..."
|
||||
)
|
||||
|
||||
elapsed = 0
|
||||
while elapsed < timeout_sec:
|
||||
await asyncio.sleep(2)
|
||||
elapsed += 2
|
||||
for selector in success_selectors:
|
||||
try:
|
||||
el = await page.query_selector(selector)
|
||||
if el is not None:
|
||||
print("[登录] 检测到登录成功!")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[登录] 超时 {timeout_sec}s,未完成登录")
|
||||
return False
|
||||
67
scripts/jiangchang_skill_core/rpa/stealth.py
Normal file
67
scripts/jiangchang_skill_core/rpa/stealth.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
|
||||
"""淡化 Playwright 自动化指纹,降低 baxia/x5sec 风控命中率。
|
||||
|
||||
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0(或 false/off/no)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||||
try {
|
||||
window.chrome = window.chrome || {};
|
||||
window.chrome.runtime = window.chrome.runtime || {};
|
||||
} catch (e) {}
|
||||
try {
|
||||
const orig = navigator.permissions && navigator.permissions.query;
|
||||
if (orig) {
|
||||
navigator.permissions.query = (p) =>
|
||||
p && p.name === 'notifications'
|
||||
? Promise.resolve({ state: Notification.permission })
|
||||
: orig(p);
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||
} catch (e) {}
|
||||
try {
|
||||
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
||||
WebGLRenderingContext.prototype.getParameter = function (p) {
|
||||
if (p === 37445) return 'Intel Inc.';
|
||||
if (p === 37446) return 'Intel Iris OpenGL Engine';
|
||||
return getParameter.call(this, p);
|
||||
};
|
||||
} catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def stealth_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def persistent_context_launch_parts(
|
||||
*, extra_args: list[str] | None = None
|
||||
) -> tuple[list[str], list[str] | None]:
|
||||
"""返回 (args, ignore_default_args)。ignore_default_args 为 None 时不要传给 launch。"""
|
||||
args = ["--start-maximized"]
|
||||
if extra_args:
|
||||
args = args + list(extra_args)
|
||||
if not stealth_enabled():
|
||||
return args, None
|
||||
if "--disable-blink-features=AutomationControlled" not in args:
|
||||
args.append("--disable-blink-features=AutomationControlled")
|
||||
return args, ["--enable-automation"]
|
||||
26
scripts/service/example_adapter/__init__.py
Normal file
26
scripts/service/example_adapter/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""示例 adapter 四档 dispatch。
|
||||
|
||||
由 ``OPENCLAW_TEST_TARGET``(或 ``config.get``)决定档位。
|
||||
复制本目录后把 ``example_adapter`` 改名为 ``<domain>_adapter``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .mock import MockAdapter
|
||||
from .real_api import RealApiAdapter
|
||||
from .real_rpa import RealRpaAdapter
|
||||
from .sim_rpa import SimRpaAdapter
|
||||
|
||||
|
||||
def get_adapter():
|
||||
"""返回当前档位 adapter 实例。"""
|
||||
target = (os.environ.get("OPENCLAW_TEST_TARGET") or "simulator_rpa").strip().lower()
|
||||
if target in ("unit", "mock"):
|
||||
return MockAdapter()
|
||||
if target == "real_api":
|
||||
return RealApiAdapter()
|
||||
if target == "real_rpa":
|
||||
return RealRpaAdapter()
|
||||
return SimRpaAdapter()
|
||||
37
scripts/service/example_adapter/base.py
Normal file
37
scripts/service/example_adapter/base.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""示例适配器数据契约与基类。
|
||||
|
||||
复制本目录为 ``<domain>_adapter/``,按 ADAPTER.md 实现各档位。
|
||||
业务逻辑只依赖 ``base`` 里的接口,不感知 mock / sim_rpa / real_* 差异。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExampleItem:
|
||||
"""单条业务输入(示例)。"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExampleResult:
|
||||
"""批量操作结果(以批为单位返回)。"""
|
||||
|
||||
ok: bool
|
||||
serial_no: Optional[str]
|
||||
error_msg: Optional[str]
|
||||
artifacts: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class AdapterBase:
|
||||
"""四档 adapter 统一接口;子类实现 ``do_batch``。"""
|
||||
|
||||
name = "base"
|
||||
|
||||
def do_batch(self, target: str, items: List[ExampleItem]) -> ExampleResult:
|
||||
raise NotImplementedError
|
||||
24
scripts/service/example_adapter/mock.py
Normal file
24
scripts/service/example_adapter/mock.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""离线 mock 档位:纯内存仿真,CI / 单测必跑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import AdapterBase, ExampleItem, ExampleResult
|
||||
|
||||
|
||||
class MockAdapter(AdapterBase):
|
||||
name = "mock"
|
||||
|
||||
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
|
||||
if not items:
|
||||
return ExampleResult(
|
||||
ok=False,
|
||||
serial_no=None,
|
||||
error_msg="empty batch",
|
||||
artifacts={},
|
||||
)
|
||||
return ExampleResult(
|
||||
ok=True,
|
||||
serial_no=f"MOCK-{len(items)}",
|
||||
error_msg=None,
|
||||
artifacts={"mode": "mock", "target": target, "count": len(items)},
|
||||
)
|
||||
14
scripts/service/example_adapter/real_api.py
Normal file
14
scripts/service/example_adapter/real_api.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""真实 API 档位占位:有官方接口时在此实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import AdapterBase, ExampleItem, ExampleResult
|
||||
|
||||
|
||||
class RealApiAdapter(AdapterBase):
|
||||
name = "real_api"
|
||||
|
||||
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
|
||||
raise NotImplementedError(
|
||||
"RealApiAdapter: 复制 example_adapter 后在此对接真实 API"
|
||||
)
|
||||
14
scripts/service/example_adapter/real_rpa.py
Normal file
14
scripts/service/example_adapter/real_rpa.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""真实 RPA 档位占位:无 API 时最后手段,谨慎实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import AdapterBase, ExampleItem, ExampleResult
|
||||
|
||||
|
||||
class RealRpaAdapter(AdapterBase):
|
||||
name = "real_rpa"
|
||||
|
||||
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
|
||||
raise NotImplementedError(
|
||||
"RealRpaAdapter: 复制 example_adapter 后在此实现生产界面 RPA"
|
||||
)
|
||||
97
scripts/service/example_adapter/sim_rpa.py
Normal file
97
scripts/service/example_adapter/sim_rpa.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""仿真 RPA 档位:演示共享库 browser / anti_detect 用法(需浏览器,默认开发档)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.rpa import anti_detect, artifacts, errors, launch_persistent_browser
|
||||
from jiangchang_skill_core.rpa.human_login import wait_for_captcha_pass
|
||||
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.runtime_paths import get_skill_data_dir, get_skill_root
|
||||
|
||||
from .base import AdapterBase, ExampleItem, ExampleResult
|
||||
|
||||
|
||||
class SimRpaAdapter(AdapterBase):
|
||||
name = "sim_rpa"
|
||||
|
||||
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
|
||||
return asyncio.run(self._do_batch_async(target, items))
|
||||
|
||||
async def _do_batch_async(self, target: str, items: list[ExampleItem]) -> ExampleResult:
|
||||
example_path = os.path.join(get_skill_root(), ".env.example")
|
||||
config.ensure_env_file(SKILL_SLUG, example_path)
|
||||
|
||||
url = config.get("TARGET_BASE_URL") or target or "https://example.com"
|
||||
batch_id = "sim-demo"
|
||||
data_dir = get_skill_data_dir()
|
||||
profile_dir = os.path.join(data_dir, "browser-profile")
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
record_video = config.get_bool("OPENCLAW_RECORD_VIDEO")
|
||||
video_dir = os.path.join(data_dir, "rpa-artifacts", batch_id, "video") if record_video else None
|
||||
if video_dir:
|
||||
os.makedirs(video_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError:
|
||||
return ExampleResult(
|
||||
ok=False,
|
||||
serial_no=None,
|
||||
error_msg="playwright not installed",
|
||||
artifacts={},
|
||||
)
|
||||
|
||||
artifact_paths: dict = {}
|
||||
try:
|
||||
async with async_playwright() as pw:
|
||||
context = await launch_persistent_browser(
|
||||
pw,
|
||||
profile_dir=profile_dir,
|
||||
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
|
||||
record_video_dir=video_dir,
|
||||
)
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
|
||||
await anti_detect.human_mouse_wiggle(page)
|
||||
|
||||
# 演示拟人输入:若页面有 search 输入框则尝试输入
|
||||
search = page.locator('input[type="search"], input[name="q"]').first
|
||||
if await search.count() > 0:
|
||||
demo_text = items[0].label if items else "openclaw-demo"
|
||||
await anti_detect.human_type(page, search, demo_text)
|
||||
await anti_detect.human_delay_short()
|
||||
|
||||
if not await wait_for_captcha_pass(
|
||||
page,
|
||||
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
|
||||
):
|
||||
shot = await artifacts.capture_failure(page, data_dir, batch_id, "captcha")
|
||||
if shot:
|
||||
artifact_paths["captcha"] = shot
|
||||
return ExampleResult(
|
||||
ok=False,
|
||||
serial_no=None,
|
||||
error_msg=errors.ERR_CAPTCHA_NEED_HUMAN,
|
||||
artifacts=artifact_paths,
|
||||
)
|
||||
|
||||
await context.close()
|
||||
except Exception as exc:
|
||||
return ExampleResult(
|
||||
ok=False,
|
||||
serial_no=None,
|
||||
error_msg=str(exc),
|
||||
artifacts=artifact_paths,
|
||||
)
|
||||
|
||||
return ExampleResult(
|
||||
ok=True,
|
||||
serial_no=f"SIM-{len(items)}",
|
||||
error_msg=None,
|
||||
artifacts={"mode": "sim_rpa", "url": url, **artifact_paths},
|
||||
)
|
||||
88
scripts/service/example_browser_rpa.py
Normal file
88
scripts/service/example_browser_rpa.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""浏览器 RPA 最小用法示例(复制到新 skill 后按需改)。
|
||||
|
||||
演示标准流程:
|
||||
1. config.ensure_env_file 首次落盘 .env
|
||||
2. launch_persistent_browser 启动 stealth persistent context
|
||||
3. 拟人操作(mouse_wiggle / human_type / human_click)
|
||||
4. 验证码 HITL 等待
|
||||
5. 失败 capture_failure 截图
|
||||
|
||||
运行(需系统 Chrome/Edge + playwright 包):
|
||||
python scripts/service/example_browser_rpa.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _scripts_dir not in sys.path:
|
||||
sys.path.insert(0, _scripts_dir)
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.rpa import (
|
||||
anti_detect,
|
||||
artifacts,
|
||||
errors,
|
||||
launch_persistent_browser,
|
||||
wait_for_captcha_pass,
|
||||
)
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.runtime_paths import get_skill_data_dir, get_skill_root
|
||||
|
||||
|
||||
async def demo() -> int:
|
||||
example_path = os.path.join(get_skill_root(), ".env.example")
|
||||
config.ensure_env_file(SKILL_SLUG, example_path)
|
||||
|
||||
url = config.get("TARGET_BASE_URL") or "https://example.com"
|
||||
data_dir = get_skill_data_dir()
|
||||
profile_dir = os.path.join(data_dir, "browser-profile")
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
batch_id = "demo"
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError:
|
||||
print("ERROR: playwright not installed")
|
||||
return 1
|
||||
|
||||
async with async_playwright() as pw:
|
||||
context = await launch_persistent_browser(
|
||||
pw,
|
||||
profile_dir=profile_dir,
|
||||
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
|
||||
)
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
|
||||
await anti_detect.human_mouse_wiggle(page)
|
||||
|
||||
search = page.locator('input[type="search"], input[name="q"]').first
|
||||
if await search.count() > 0:
|
||||
await anti_detect.human_type(page, search, "openclaw-rpa-demo")
|
||||
await anti_detect.human_click(page, selector='input[type="search"]')
|
||||
|
||||
if not await wait_for_captcha_pass(
|
||||
page,
|
||||
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
|
||||
):
|
||||
shot = await artifacts.capture_failure(page, data_dir, batch_id, "captcha")
|
||||
print(errors.ERR_CAPTCHA_NEED_HUMAN, shot or "")
|
||||
return 1
|
||||
|
||||
print("OK browser RPA demo finished")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
shot = await artifacts.capture_failure(page, data_dir, batch_id, "error")
|
||||
print(f"ERROR: {exc}", shot or "")
|
||||
return 1
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(demo()))
|
||||
Reference in New Issue
Block a user