重新调整模板
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
# 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",
|
||||
]
|
||||
@@ -1,126 +0,0 @@
|
||||
# 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
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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]
|
||||
|
||||
try:
|
||||
from .video_session import RpaVideoSession
|
||||
except ImportError:
|
||||
RpaVideoSession = None # type: ignore[misc, assignment]
|
||||
|
||||
__all__ = [
|
||||
"anti_detect",
|
||||
"artifacts",
|
||||
"errors",
|
||||
"human_login",
|
||||
"stealth",
|
||||
"launch_persistent_browser",
|
||||
"RpaVideoSession",
|
||||
# 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,
|
||||
)
|
||||
@@ -1,216 +0,0 @@
|
||||
# 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))
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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
|
||||
@@ -1,58 +0,0 @@
|
||||
"""统一 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,
|
||||
) -> "BrowserContext":
|
||||
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
||||
|
||||
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
||||
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
||||
"""
|
||||
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
|
||||
|
||||
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
|
||||
if stealth_enabled():
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
return context
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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",
|
||||
]
|
||||
@@ -1,97 +0,0 @@
|
||||
# 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
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,461 +0,0 @@
|
||||
"""RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, IO, List, Optional
|
||||
|
||||
from .. import config
|
||||
|
||||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||
"FontName=Arial,FontSize=14,"
|
||||
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||||
"Outline=2,Shadow=1,Alignment=2"
|
||||
)
|
||||
|
||||
_DEFAULT_MUSIC_ROOT = os.environ.get(
|
||||
"MEDIA_ASSETS_ROOT",
|
||||
r"D:\OpenClaw\client-commons\media-assets",
|
||||
)
|
||||
|
||||
_CAPTURE_FRAMERATE = 15
|
||||
|
||||
|
||||
def _record_video_enabled() -> bool:
|
||||
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
|
||||
|
||||
|
||||
def _resolve_music_file() -> Optional[Path]:
|
||||
music_dir = Path(_DEFAULT_MUSIC_ROOT) / "music"
|
||||
if not music_dir.is_dir():
|
||||
return None
|
||||
files = list(music_dir.rglob("*.mp3"))
|
||||
if not files:
|
||||
return None
|
||||
return random.choice(files)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StepEntry:
|
||||
start: float
|
||||
text: str
|
||||
duration: float = 4.0
|
||||
|
||||
|
||||
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
|
||||
MIN_DURATION = 1.0
|
||||
GAP = 0.1
|
||||
steps = sorted(entries, key=lambda e: e.start)
|
||||
if not steps:
|
||||
steps = [_StepEntry(0.0, "任务执行中", 4.0)]
|
||||
|
||||
for i in range(len(steps) - 1):
|
||||
cur, nxt = steps[i], steps[i + 1]
|
||||
max_end = nxt.start - GAP
|
||||
new_dur = max_end - cur.start
|
||||
if new_dur < MIN_DURATION:
|
||||
cur.duration = MIN_DURATION
|
||||
nxt.start = cur.start + MIN_DURATION + GAP
|
||||
else:
|
||||
cur.duration = min(cur.duration, new_dur)
|
||||
|
||||
def _fmt(s: float) -> str:
|
||||
h = int(s // 3600)
|
||||
m = int((s % 3600) // 60)
|
||||
sec = int(s % 60)
|
||||
ms = int((s % 1) * 1000)
|
||||
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
|
||||
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for i, e in enumerate(steps, 1):
|
||||
f.write(f"{i}\n")
|
||||
f.write(f"{_fmt(e.start)} --> {_fmt(e.start + e.duration)}\n")
|
||||
f.write(f"{e.text}\n\n")
|
||||
|
||||
|
||||
def _escape_srt_path(path: str) -> str:
|
||||
return path.replace("\\", "/").replace(":", "\\:")
|
||||
|
||||
|
||||
def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False, "ffmpeg not found"
|
||||
if result.returncode != 0:
|
||||
err = (result.stderr or result.stdout or "").strip()
|
||||
return False, err[:500] if err else f"exit {result.returncode}"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
|
||||
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"gdigrab",
|
||||
"-framerate",
|
||||
str(_CAPTURE_FRAMERATE),
|
||||
"-i",
|
||||
"desktop",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
capture_path,
|
||||
]
|
||||
|
||||
|
||||
def _compose_capture_mp4(
|
||||
capture_path: Path,
|
||||
srt_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
music_file: Optional[Path] = None,
|
||||
music_volume: float = 0.15,
|
||||
) -> tuple[bool, str]:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
srt_esc = _escape_srt_path(str(srt_path.resolve()))
|
||||
subtitle_filter = (
|
||||
f"subtitles='{srt_esc}'"
|
||||
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
|
||||
)
|
||||
|
||||
if music_file and music_file.is_file():
|
||||
filter_complex = (
|
||||
f"[0:v]{subtitle_filter}[vout];"
|
||||
f"[1:a]volume={music_volume},apad[aout]"
|
||||
)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_path),
|
||||
"-i",
|
||||
str(music_file),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(output_path),
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_path),
|
||||
"-vf",
|
||||
subtitle_filter,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(output_path),
|
||||
]
|
||||
return _run_ffmpeg(cmd)
|
||||
|
||||
|
||||
class RpaVideoSession:
|
||||
"""Service/RPA 层统一 ffmpeg 桌面录屏;OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
skill_slug: str,
|
||||
skill_data_dir: str,
|
||||
batch_id: str,
|
||||
title: str = "",
|
||||
) -> None:
|
||||
self.skill_slug = skill_slug
|
||||
self.skill_data_dir = os.path.abspath(skill_data_dir)
|
||||
self.batch_id = batch_id
|
||||
self.title = title
|
||||
self.enabled = _record_video_enabled()
|
||||
self.warnings: List[str] = []
|
||||
self.output_video_path: Optional[str] = None
|
||||
self.capture_path: Optional[str] = None
|
||||
self.artifacts: Dict[str, Any] = {}
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_batch = "".join(c if c.isalnum() or c in "-_" else "_" for c in batch_id)
|
||||
self._video_stem = f"{skill_slug}_{ts}_{safe_batch}"
|
||||
|
||||
self._videos_dir = os.path.join(self.skill_data_dir, "videos")
|
||||
self._artifact_root = os.path.join(
|
||||
self.skill_data_dir, "rpa-artifacts", batch_id
|
||||
)
|
||||
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
|
||||
self._logs_dir = os.path.join(self._artifact_root, "logs")
|
||||
self._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log")
|
||||
self._capture_path = os.path.join(self._artifact_root, "capture.mp4")
|
||||
self._srt_path = os.path.join(self._subtitles_dir, f"{self._video_stem}.srt")
|
||||
self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
|
||||
|
||||
self._steps: List[_StepEntry] = []
|
||||
self._t0: Optional[float] = None
|
||||
self._ffmpeg_proc: Optional[subprocess.Popen] = None
|
||||
self._ffmpeg_record_log_file: Optional[IO[str]] = None
|
||||
|
||||
if self.enabled:
|
||||
os.makedirs(self._videos_dir, exist_ok=True)
|
||||
os.makedirs(self._subtitles_dir, exist_ok=True)
|
||||
os.makedirs(self._logs_dir, exist_ok=True)
|
||||
os.makedirs(self._artifact_root, exist_ok=True)
|
||||
|
||||
def _record_log_hint(self) -> str:
|
||||
if self.enabled and self._ffmpeg_record_log_path:
|
||||
return f"; record_log={self._ffmpeg_record_log_path}"
|
||||
return ""
|
||||
|
||||
def _append_warning(self, code: str, detail: str = "") -> None:
|
||||
msg = code if not detail else f"{code}: {detail}"
|
||||
if code in (
|
||||
"ffmpeg_record_start_failed",
|
||||
"ffmpeg_capture_missing",
|
||||
"ffmpeg_compose_failed",
|
||||
):
|
||||
msg += self._record_log_hint()
|
||||
self.warnings.append(msg)
|
||||
|
||||
def step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
self.add_step(text, duration=duration)
|
||||
|
||||
def add_step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
"""记录一步骤字幕(相对录屏开始时间)。"""
|
||||
if not self.enabled:
|
||||
return
|
||||
if self._t0 is None:
|
||||
self._t0 = time.monotonic()
|
||||
elapsed = time.monotonic() - self._t0
|
||||
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
|
||||
|
||||
def _close_ffmpeg_record_log(self) -> None:
|
||||
fh = self._ffmpeg_record_log_file
|
||||
self._ffmpeg_record_log_file = None
|
||||
if fh is None:
|
||||
return
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _start_ffmpeg_capture(self) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if not shutil.which("ffmpeg"):
|
||||
self.warnings.append("ffmpeg_not_found")
|
||||
return
|
||||
|
||||
cmd = _build_desktop_record_cmd(self._capture_path)
|
||||
if cmd is None:
|
||||
self._append_warning(
|
||||
"ffmpeg_record_start_failed",
|
||||
"desktop capture only supported on Windows",
|
||||
)
|
||||
return
|
||||
|
||||
stderr_target: Any = subprocess.DEVNULL
|
||||
try:
|
||||
os.makedirs(self._logs_dir, exist_ok=True)
|
||||
self._ffmpeg_record_log_file = open(
|
||||
self._ffmpeg_record_log_path, "a", encoding="utf-8"
|
||||
)
|
||||
stderr_target = self._ffmpeg_record_log_file
|
||||
except Exception as exc:
|
||||
self.warnings.append(f"ffmpeg_record_log_open_failed: {exc}")
|
||||
|
||||
try:
|
||||
self._ffmpeg_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=stderr_target,
|
||||
)
|
||||
self.capture_path = self._capture_path
|
||||
except Exception as exc:
|
||||
self._append_warning("ffmpeg_record_start_failed", str(exc))
|
||||
self._ffmpeg_proc = None
|
||||
self._close_ffmpeg_record_log()
|
||||
|
||||
def _stop_ffmpeg_capture(self) -> None:
|
||||
proc = self._ffmpeg_proc
|
||||
if proc is None:
|
||||
self._close_ffmpeg_record_log()
|
||||
return
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
if proc.stdin:
|
||||
proc.stdin.write(b"q")
|
||||
proc.stdin.flush()
|
||||
except Exception:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
self._ffmpeg_proc = None
|
||||
self._close_ffmpeg_record_log()
|
||||
time.sleep(0.3)
|
||||
|
||||
async def __aenter__(self) -> "RpaVideoSession":
|
||||
if self.enabled:
|
||||
self._t0 = time.monotonic()
|
||||
if self.title:
|
||||
self.add_step(self.title, duration=3.0)
|
||||
self._start_ffmpeg_capture()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
if self.enabled:
|
||||
self._stop_ffmpeg_capture()
|
||||
await self.finalize()
|
||||
|
||||
async def finalize(self) -> None:
|
||||
"""停止录制后合成最终 MP4;失败只记 warning,不抛异常。"""
|
||||
if not self.enabled:
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
if not self._steps and self.title:
|
||||
self.add_step(self.title)
|
||||
|
||||
try:
|
||||
capture = Path(self._capture_path)
|
||||
if not capture.is_file() or capture.stat().st_size <= 0:
|
||||
self._append_warning("ffmpeg_capture_missing")
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
self.capture_path = str(capture.resolve())
|
||||
|
||||
entries = list(self._steps)
|
||||
if entries:
|
||||
entries[-1].duration = max(entries[-1].duration, 5.0)
|
||||
try:
|
||||
_generate_srt(entries, self._srt_path)
|
||||
except Exception as exc:
|
||||
self.warnings.append(f"subtitle_write_failed: {exc}")
|
||||
|
||||
music = _resolve_music_file()
|
||||
if music is None:
|
||||
self.warnings.append("background_music_missing")
|
||||
|
||||
srt_file = Path(self._srt_path)
|
||||
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
|
||||
|
||||
ok, err = _compose_capture_mp4(
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
music_file=music,
|
||||
)
|
||||
if not ok and music is not None:
|
||||
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||||
ok, err = _compose_capture_mp4(
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
music_file=None,
|
||||
)
|
||||
if not ok and not use_srt:
|
||||
ok, err = _run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(self._planned_output),
|
||||
]
|
||||
)
|
||||
if not ok:
|
||||
self._append_warning("ffmpeg_compose_failed", err)
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
self.output_video_path = str(Path(self._planned_output).resolve())
|
||||
except Exception as exc:
|
||||
self.warnings.append(f"video_finalize_error: {exc}")
|
||||
|
||||
self.artifacts = self.summary()
|
||||
|
||||
def summary(self) -> Dict[str, Any]:
|
||||
record_log = (
|
||||
os.path.abspath(self._ffmpeg_record_log_path)
|
||||
if self.enabled and self._ffmpeg_record_log_path
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"path": self.output_video_path,
|
||||
"capture_path": self.capture_path,
|
||||
"record_log_path": record_log,
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
def to_artifact_dict(self) -> Dict[str, Any]:
|
||||
data = self.summary()
|
||||
data["batch_id"] = self.batch_id
|
||||
return data
|
||||
@@ -1,109 +0,0 @@
|
||||
"""
|
||||
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
|
||||
|
||||
模板复制后通常无需大改;如组织环境不同,再按项目实际调整。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
CLI_LOCAL_DEV_ENABLED = True
|
||||
DEFAULT_LOCAL_USER_ID = "10032"
|
||||
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
|
||||
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
|
||||
|
||||
|
||||
def platform_default_data_root() -> str:
|
||||
if sys.platform == "win32":
|
||||
return WIN_DEFAULT_DATA_ROOT
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip()
|
||||
if env:
|
||||
return env
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip() or "_anon"
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
if not path or not os.path.isdir(path):
|
||||
return False
|
||||
# 通过常见基础技能存在与否,判断这是不是 skills 根目录
|
||||
# 这里列举的是平台基础设施类技能,不包含具体业务技能
|
||||
for marker in (
|
||||
"llm-manager",
|
||||
"skill-template",
|
||||
):
|
||||
if os.path.isdir(os.path.join(path, marker)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_skills_root() -> str:
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||
if sys.platform == "win32" and not app:
|
||||
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
|
||||
if app:
|
||||
nested = os.path.join(app, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(app):
|
||||
return os.path.normpath(app)
|
||||
|
||||
if sys.platform == "win32":
|
||||
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
|
||||
return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT)
|
||||
|
||||
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
|
||||
|
||||
|
||||
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
if skill_scripts_dir:
|
||||
scripts = os.path.abspath(skill_scripts_dir)
|
||||
skill_root = os.path.dirname(scripts)
|
||||
inferred = os.path.dirname(skill_root)
|
||||
if _looks_like_skills_root(inferred):
|
||||
return os.path.normpath(inferred)
|
||||
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
return get_skills_root()
|
||||
|
||||
|
||||
def apply_cli_local_defaults() -> None:
|
||||
enabled = CLI_LOCAL_DEV_ENABLED
|
||||
if not enabled:
|
||||
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (os.getenv("CLAW_DATA_ROOT") or os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||
if not (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||
@@ -1,213 +0,0 @@
|
||||
"""
|
||||
统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
|
||||
按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .runtime_env import get_data_root, get_user_id
|
||||
|
||||
_skill_slug: str = ""
|
||||
_logger_name: str = ""
|
||||
|
||||
# 避免 logging 内部错误再向 stderr 打印 "--- Logging error ---"
|
||||
logging.raiseExceptions = False
|
||||
|
||||
|
||||
def get_unified_logs_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), "logs")
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def get_skill_log_file_path() -> str:
|
||||
"""与 setup 写入的主日志文件一致(供终端提示等)。"""
|
||||
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
|
||||
if override:
|
||||
parent = os.path.dirname(os.path.abspath(override))
|
||||
if parent:
|
||||
try:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return os.path.abspath(override)
|
||||
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
|
||||
|
||||
|
||||
def _can_open_log_file(log_path: str) -> Tuple[bool, str]:
|
||||
"""探测日志文件是否可追加写入(真实 open,非 os.access)。"""
|
||||
try:
|
||||
parent = os.path.dirname(os.path.abspath(log_path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(log_path, "a", encoding="utf-8"):
|
||||
pass
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def ensure_trace_for_process() -> str:
|
||||
"""本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。"""
|
||||
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||
if existing:
|
||||
return existing
|
||||
tid = uuid.uuid4().hex[:12]
|
||||
os.environ["JIANGCHANG_TRACE_ID"] = tid
|
||||
return tid
|
||||
|
||||
|
||||
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
|
||||
"""subprocess.run(..., env=...) 时使用,保证带上当前 trace。"""
|
||||
ensure_trace_for_process()
|
||||
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||
if not tid:
|
||||
tid = ensure_trace_for_process()
|
||||
base = os.environ if environ is None else environ
|
||||
return {**base, "JIANGCHANG_TRACE_ID": tid}
|
||||
|
||||
|
||||
class _SkillContextFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-"
|
||||
record.skill_slug = _skill_slug or "-"
|
||||
return True
|
||||
|
||||
|
||||
_FORMAT = (
|
||||
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
|
||||
)
|
||||
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
|
||||
|
||||
|
||||
def _log_level_from_env() -> int:
|
||||
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
|
||||
return getattr(logging, v, None) or logging.INFO
|
||||
|
||||
|
||||
def _backup_count() -> int:
|
||||
try:
|
||||
n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip())
|
||||
return max(1, min(n, 365))
|
||||
except ValueError:
|
||||
return 30
|
||||
|
||||
|
||||
def _clear_logger_handlers(log: logging.Logger) -> None:
|
||||
for h in list(log.handlers):
|
||||
log.removeHandler(h)
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _attach_fallback_handler(log: logging.Logger) -> None:
|
||||
"""文件日志不可用时仅挂 NullHandler,不向 stderr 打印 logging 异常。"""
|
||||
log.addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
||||
"""
|
||||
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
|
||||
须在进程早期调用(如 CLI main 在业务日志之前)。
|
||||
文件日志不可写时降级为 NullHandler,不影响 CLI 返回码。
|
||||
"""
|
||||
global _skill_slug, _logger_name
|
||||
logging.raiseExceptions = False
|
||||
ensure_trace_for_process()
|
||||
_skill_slug = skill_slug
|
||||
_logger_name = logger_name
|
||||
|
||||
log = logging.getLogger(logger_name)
|
||||
_clear_logger_handlers(log)
|
||||
log.setLevel(_log_level_from_env())
|
||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||
filt = _SkillContextFilter()
|
||||
|
||||
log_path = get_skill_log_file_path()
|
||||
ok, _err = _can_open_log_file(log_path)
|
||||
if ok:
|
||||
try:
|
||||
fh = TimedRotatingFileHandler(
|
||||
log_path,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=_backup_count(),
|
||||
encoding="utf-8",
|
||||
delay=False,
|
||||
)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(filt)
|
||||
log.addHandler(fh)
|
||||
except Exception:
|
||||
_attach_fallback_handler(log)
|
||||
else:
|
||||
_attach_fallback_handler(log)
|
||||
|
||||
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
):
|
||||
try:
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(logging.WARNING)
|
||||
sh.setFormatter(fmt)
|
||||
sh.addFilter(filt)
|
||||
log.addHandler(sh)
|
||||
except Exception:
|
||||
pass
|
||||
log.propagate = False
|
||||
|
||||
|
||||
def get_skill_logger() -> logging.Logger:
|
||||
if not _logger_name:
|
||||
raise RuntimeError("get_skill_logger: call setup_skill_logging first")
|
||||
return logging.getLogger(_logger_name)
|
||||
|
||||
|
||||
def attach_unified_file_handler(
|
||||
log_path: str,
|
||||
*,
|
||||
skill_slug: str,
|
||||
logger_name: str,
|
||||
level: int = logging.DEBUG,
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
|
||||
"""
|
||||
global _skill_slug
|
||||
logging.raiseExceptions = False
|
||||
ensure_trace_for_process()
|
||||
_skill_slug = skill_slug
|
||||
lg = logging.getLogger(logger_name)
|
||||
_clear_logger_handlers(lg)
|
||||
lg.setLevel(level)
|
||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||
filt = _SkillContextFilter()
|
||||
|
||||
ok, _err = _can_open_log_file(log_path)
|
||||
if ok:
|
||||
try:
|
||||
fh = logging.FileHandler(log_path, encoding="utf-8", delay=False)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(filt)
|
||||
lg.addHandler(fh)
|
||||
except Exception:
|
||||
_attach_fallback_handler(lg)
|
||||
else:
|
||||
_attach_fallback_handler(lg)
|
||||
lg.propagate = False
|
||||
return lg
|
||||
@@ -5,6 +5,9 @@ skill-template CLI 入口。
|
||||
|
||||
复制为新技能后,请修改注释与常量,但保留当前分层结构:
|
||||
cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。
|
||||
|
||||
jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit,
|
||||
不在技能仓库内 vendored。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""兄弟技能 CLI 调用模板。
|
||||
"""兄弟技能 CLI 调用工具。
|
||||
|
||||
通用工具:通过子进程调用同级其他 skill 的 main.py,并解析 JSON 输出。
|
||||
具体调用哪些兄弟技能,由具体业务 skill 决定(在 task_service.py 中按需 import 调用)。
|
||||
通过子进程调用同级其他 skill 的 main.py,并解析 JSON 输出。
|
||||
具体调用哪些兄弟技能,由复制后的业务 skill 在 service 层按需决定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -50,16 +50,8 @@ def call_sibling_json(skill_slug: str, args: List[str]) -> Optional[Dict[str, An
|
||||
def get_sibling_main_path(skill_slug: str) -> str:
|
||||
"""获取兄弟技能的 main.py 绝对路径。
|
||||
|
||||
使用示例(在你的 task_service.py 中):
|
||||
使用示例(在 task_service.py 中):
|
||||
from service.sibling_bridge import get_sibling_main_path, call_sibling_json
|
||||
# 调用名为 account-manager 的兄弟技能:
|
||||
result = call_sibling_json("account-manager", ["list", "--limit", "10"])
|
||||
"""
|
||||
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 复制本模板后,按需在你自己的 task_service.py 中调用上面的工具。
|
||||
# 不要在本文件中硬编码具体兄弟技能函数(如 get_account_manager_main_path),
|
||||
# 那是发布类技能的特定需求,不属于通用模板。
|
||||
# ============================================================
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from jiangchang_skill_core import collect_runtime_diagnostics, format_runtime_health_lines
|
||||
|
||||
from db import task_logs_repository as tlr
|
||||
from service.entitlement_service import check_entitlement
|
||||
from util.constants import SKILL_SLUG, SKILL_VERSION
|
||||
from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
|
||||
from util.runtime_paths import get_skill_root
|
||||
from util.timeutil import unix_to_iso
|
||||
|
||||
|
||||
@@ -89,7 +91,16 @@ def cmd_log_get(log_id: str) -> int:
|
||||
|
||||
|
||||
def cmd_health() -> int:
|
||||
return 0 if sys.version_info >= (3, 10) else 1
|
||||
runtime = collect_runtime_diagnostics(
|
||||
skill_slug=SKILL_SLUG,
|
||||
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
|
||||
skill_root=get_skill_root(),
|
||||
)
|
||||
status = "failed" if runtime.has_fatal_issues else "ok"
|
||||
print(f"{SKILL_SLUG} health: {status}")
|
||||
for line in format_runtime_health_lines(runtime):
|
||||
print(line)
|
||||
return 1 if runtime.has_fatal_issues else 0
|
||||
|
||||
|
||||
def cmd_version() -> int:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""技能标识与版本(复制后请修改)。"""
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.14"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.0.11"
|
||||
|
||||
Reference in New Issue
Block a user