重新调整模板

This commit is contained in:
2026-06-07 11:28:45 +08:00
parent ae5466854f
commit 1bbcc65cf3
31 changed files with 448 additions and 1525 deletions

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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))

View File

@@ -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

View File

@@ -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。
录屏由 RpaVideoSessionffmpeg负责本函数不向 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

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -5,6 +5,9 @@ skill-template CLI 入口。
复制为新技能后,请修改注释与常量,但保留当前分层结构:
cliargv→ service业务编排→ db持久化→ util通用工具
jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit
不在技能仓库内 vendored。
"""
from __future__ import annotations

View File

@@ -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
# 那是发布类技能的特定需求,不属于通用模板。
# ============================================================

View File

@@ -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:

View File

@@ -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"