Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c51139883 | |||
| 1632828372 | |||
| f7fc33fa4a | |||
| 5a0b93448c | |||
| 367df10755 | |||
| 857e7e3d73 | |||
| 0e2b73e1ad | |||
| 36a411810d | |||
| c9398451d0 | |||
| 2a542c0be6 | |||
| e59661f2d8 | |||
| 434a405e19 | |||
| aab0207aec | |||
| eba90fb8b9 | |||
| 21d4907f02 | |||
| fb2fa20306 | |||
| 4a9d358aef | |||
| f3fc7b888b | |||
| 84bb085250 | |||
| 8a4a3c8f5d | |||
| 9d4068e4af | |||
| abe02f26cf | |||
| ea25fc2638 | |||
| 43ec2d66a3 | |||
| 311441dab0 | |||
| 6e3f79dde9 | |||
| ffb64b2cfc | |||
| c8d136fb79 | |||
| 34f9a2c141 | |||
| 4fc4cfb802 | |||
| 089f00c3ff | |||
| 20294fd23a |
7
.github/workflows/publish.yml
vendored
7
.github/workflows/publish.yml
vendored
@@ -2,9 +2,8 @@ name: Publish Python Package to Gitea
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches:
|
||||||
- 'v*'
|
- main
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
@@ -36,4 +35,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python3.12 -m twine upload \
|
python3.12 -m twine upload \
|
||||||
--repository-url https://git.jc2009.com/api/packages/client-jiangchang/pypi \
|
--repository-url https://git.jc2009.com/api/packages/client-jiangchang/pypi \
|
||||||
dist/*
|
dist/*
|
||||||
|
|||||||
23
.github/workflows/reusable-release-skill.yaml
vendored
23
.github/workflows/reusable-release-skill.yaml
vendored
@@ -108,11 +108,32 @@ jobs:
|
|||||||
' shutil.copytree(src, dst, ignore=ignore_fn, dirs_exist_ok=True)' \
|
' shutil.copytree(src, dst, ignore=ignore_fn, dirs_exist_ok=True)' \
|
||||||
'' \
|
'' \
|
||||||
"shutil.copy2('SKILL.md', os.path.join(PACKAGE, 'SKILL.md'))" \
|
"shutil.copy2('SKILL.md', os.path.join(PACKAGE, 'SKILL.md'))" \
|
||||||
|
"readme_src = os.path.abspath('README.md')" \
|
||||||
|
"readme_dst = os.path.join(PACKAGE, 'README.md')" \
|
||||||
|
'if os.path.isfile(readme_src):' \
|
||||||
|
" shutil.copy2(readme_src, readme_dst)" \
|
||||||
|
" print('Copied README.md')" \
|
||||||
|
'if os.path.isfile(readme_src) and not os.path.isfile(readme_dst):' \
|
||||||
|
" raise RuntimeError('README.md exists in skill root but was not packaged')" \
|
||||||
"copy_dir('references', os.path.join(PACKAGE, 'references'), references_ignore)" \
|
"copy_dir('references', os.path.join(PACKAGE, 'references'), references_ignore)" \
|
||||||
"copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \
|
"copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \
|
||||||
"copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \
|
"copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \
|
||||||
"copy_dir('evals', os.path.join(PACKAGE, 'evals'), common_ignore)" \
|
"copy_dir('evals', os.path.join(PACKAGE, 'evals'), common_ignore)" \
|
||||||
'' \
|
'' \
|
||||||
|
"req_src = os.path.abspath('requirements.txt')" \
|
||||||
|
"req_dst = os.path.join(PACKAGE, 'requirements.txt')" \
|
||||||
|
'if os.path.isfile(req_src):' \
|
||||||
|
" shutil.copy2(req_src, req_dst)" \
|
||||||
|
" print('Copied requirements.txt')" \
|
||||||
|
'' \
|
||||||
|
"env_example_src = os.path.abspath('.env.example')" \
|
||||||
|
"env_example_dst = os.path.join(PACKAGE, '.env.example')" \
|
||||||
|
'if os.path.isfile(env_example_src):' \
|
||||||
|
" shutil.copy2(env_example_src, env_example_dst)" \
|
||||||
|
" print('Copied .env.example')" \
|
||||||
|
'if os.path.isfile(env_example_src) and not os.path.isfile(env_example_dst):' \
|
||||||
|
" raise RuntimeError('.env.example exists in skill root but was not packaged')" \
|
||||||
|
'' \
|
||||||
"print('Package top-level entries:')" \
|
"print('Package top-level entries:')" \
|
||||||
'for name in sorted(os.listdir(PACKAGE)):' \
|
'for name in sorted(os.listdir(PACKAGE)):' \
|
||||||
" print(' -', name)" \
|
" print(' -', name)" \
|
||||||
@@ -130,7 +151,7 @@ jobs:
|
|||||||
skill_readme_md = (post.content or '').strip()
|
skill_readme_md = (post.content or '').strip()
|
||||||
skill_description = metadata.get('description')
|
skill_description = metadata.get('description')
|
||||||
metadata['readme_md'] = skill_readme_md
|
metadata['readme_md'] = skill_readme_md
|
||||||
readme_path = os.path.join('references', 'README.md')
|
readme_path = 'README.md'
|
||||||
if os.path.isfile(readme_path):
|
if os.path.isfile(readme_path):
|
||||||
try:
|
try:
|
||||||
readme_post = frontmatter.load(readme_path)
|
readme_post = frontmatter.load(readme_path)
|
||||||
|
|||||||
240
README.md
240
README.md
@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
|
|||||||
|
|
||||||
## 3. 包含的模块
|
## 3. 包含的模块
|
||||||
|
|
||||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析等,供 Skill CLI / 服务进程使用。
|
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||||
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
|
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
|
||||||
|
|
||||||
## 4. 快速开始
|
## 4. 快速开始
|
||||||
@@ -54,12 +54,238 @@ finally:
|
|||||||
client.disconnect()
|
client.disconnect()
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. 发版流程(维护者)
|
### 桌面 E2E(`jiangchang_desktop_sdk.e2e_helpers`)
|
||||||
|
|
||||||
```bash
|
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
|
||||||
git tag v0.1.0
|
|
||||||
git push origin v0.1.0
|
推荐 preflight 流程:
|
||||||
# CI 自动打包并发布到 Gitea Package Registry(见 .github/workflows/publish.yml)
|
|
||||||
|
1. `require_logged_in_skill_data_dir(page, skill_slug)` — 经 `jiangchang:resolve-skill-data-dir` 解析路径,并断言已登录(非 `_anon` / `anon` / `anonymous`)。
|
||||||
|
2. `assert_skill_env_file(ctx, ["API_KEY", ...])` — 检查 `{skill_data}/config/.env` 中必填项已配置且非占位符;E2E 不会自动生成真实密钥。
|
||||||
|
3. `send_prompt_via_composer(page, prompt)` — 普通文本逐字键入;内容中的 `\n` / `\r\n` / `\r` 用 **Shift+Enter** 输入换行;最后用 **Enter** 发送(禁止 DOM 注入、剪贴板、JS 伪造 input/change)。
|
||||||
|
|
||||||
|
诊断时可调用 `get_runtime_env(page)` 查看主进程解析的环境,**不要**用它拼用户数据路径。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jiangchang_desktop_sdk.e2e_helpers import (
|
||||||
|
assert_skill_env_file,
|
||||||
|
require_logged_in_skill_data_dir,
|
||||||
|
send_prompt_via_composer,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = require_logged_in_skill_data_dir(page, "demo-skill")
|
||||||
|
assert_skill_env_file(ctx, ["API_KEY"])
|
||||||
|
send_prompt_via_composer(page, "第一行\n第二行")
|
||||||
```
|
```
|
||||||
|
|
||||||
仓库内还保留 `examples/`、`tools/`、`python-runtime/` 目录及既有 Skill / 前端相关的 GitHub Actions 工作流,与本 PyPI 包的发布彼此独立。
|
## Skill Activity Stream / Job 运行时
|
||||||
|
|
||||||
|
长任务(批量生成、RPA)需要把**逐步进度**推到匠厂宿主 UI,而不能依赖 Agent 轮询或 OpenClaw stdout patch。`jiangchang_skill_core.activity` 提供 **Skill Activity Stream (SAS) v1** 契约:
|
||||||
|
|
||||||
|
| API | 作用 |
|
||||||
|
|-----|------|
|
||||||
|
| `emit(text, ...)` | 写 Run Journal(`activity` / `progress` / `warn` / `debug`) |
|
||||||
|
| `step(text)` | `emit(type='activity')` 别名 |
|
||||||
|
| `finish(status=..., **fields)` | 写终态事件 + **唯一**向 stdout 输出单行 result JSON |
|
||||||
|
| `rpa_step(name)` | 装饰器:进入/成功/失败自动 emit |
|
||||||
|
| `job_context(...)` | 可选包裹 main;异常时自动 `finish(failed)` |
|
||||||
|
|
||||||
|
### Run Journal 路径(宿主 tail 此文件)
|
||||||
|
|
||||||
|
```
|
||||||
|
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
- `job_id`:优先 `JIANGCHANG_JOB_ID`,否则进程内 `uuid4().hex`(首次 emit 时生成并缓存)
|
||||||
|
- `JIANGCHANG_RUN_MODE`:`job` | `cli`(默认 `cli`),首行 meta 事件记录
|
||||||
|
- NDJSON,UTF-8,append-only,每行 flush
|
||||||
|
- `debug` 类型仅当 `JIANGCHANG_ACTIVITY_DEBUG=1` 时写入
|
||||||
|
|
||||||
|
### 与 Agent / OpenClaw 的分工
|
||||||
|
|
||||||
|
- **`emit()` / journal**:永不 print 到 stdout,避免 bash tool 结果淹没 Agent context
|
||||||
|
- **`finish()`**:唯一允许向 stdout 写业务结果的 API;单行 compact JSON,`status` + 摘要字段,默认 ≤4096 字符
|
||||||
|
- 技能里手写 `print("OK")`、pretty JSON 应逐步改为 `finish()`;RPA 步骤用 `emit("打开浏览器")` 或 `@rpa_step`
|
||||||
|
|
||||||
|
### GEO batch 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jiangchang_skill_core.activity import emit, finish
|
||||||
|
|
||||||
|
for i, item in enumerate(items, 1):
|
||||||
|
emit(f"生成 {item['title']}", type="progress", current=i, total=len(items), skill="geo-batch")
|
||||||
|
# ... business logic ...
|
||||||
|
|
||||||
|
finish(status="success", skill="geo-batch", generated=len(items))
|
||||||
|
```
|
||||||
|
|
||||||
|
### RPA 步骤示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jiangchang_skill_core.activity import emit, rpa_step
|
||||||
|
|
||||||
|
@rpa_step("打开浏览器")
|
||||||
|
def open_browser(page):
|
||||||
|
page.goto("https://example.com")
|
||||||
|
|
||||||
|
emit("手动步骤说明", skill="publish-toutiao", stage="rpa")
|
||||||
|
```
|
||||||
|
|
||||||
|
`RpaVideoSession.add_step()` 在录屏字幕之外默认同步 `emit(..., stage="rpa.video")`;可通过 `emit_activity=False` 关闭。
|
||||||
|
|
||||||
|
### 兄弟技能 CLI 桥
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jiangchang_skill_core import call_sibling_json, get_sibling_main_path
|
||||||
|
|
||||||
|
accounts = call_sibling_json("account-manager", ["list", "all"])
|
||||||
|
```
|
||||||
|
|
||||||
|
同步 `subprocess.run`,`capture_output=True`,自动带上 `subprocess_env_with_trace()`。
|
||||||
|
|
||||||
|
## 共享媒体资源
|
||||||
|
|
||||||
|
`jiangchang_skill_core.media_assets` 提供共享媒体资源解析能力。默认会从:
|
||||||
|
|
||||||
|
`{JIANGCHANG_DATA_ROOT}/shared/media-assets`
|
||||||
|
|
||||||
|
读取背景音乐、字体、水印和 ffmpeg 工具。
|
||||||
|
|
||||||
|
如果目录不存在,会优先下载稳定 release bundle(不依赖用户电脑安装 Git):
|
||||||
|
|
||||||
|
`https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip`
|
||||||
|
|
||||||
|
bundle 内含背景音乐、字体与水印;**不包含** ffmpeg 二进制。ffmpeg / ffprobe 仍由 bundle 内 `manifest.json` 的 `tools.ffmpeg` 下载源单独拉取。
|
||||||
|
|
||||||
|
可通过环境变量覆盖:
|
||||||
|
|
||||||
|
- `MEDIA_ASSETS_ROOT=/path/to/custom/media-assets` — 本地资源目录
|
||||||
|
- `MEDIA_ASSETS_BUNDLE_URL=https://example.com/media-assets.zip` — bundle 下载地址
|
||||||
|
|
||||||
|
### Runtime diagnostics(统一 health 诊断)
|
||||||
|
|
||||||
|
`jiangchang_skill_core.runtime_diagnostics` 提供各 Skill 共用的 Runtime / media-assets / ffmpeg 探测,避免每个技能各自复制检查逻辑。Skill 的 `health` 命令可调用:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jiangchang_skill_core import (
|
||||||
|
collect_runtime_diagnostics,
|
||||||
|
format_runtime_health_lines,
|
||||||
|
runtime_diagnostics_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = collect_runtime_diagnostics(
|
||||||
|
skill_slug="my-skill",
|
||||||
|
platform_kit_min_version="1.0.10", # 可选;省略则只报告当前版本
|
||||||
|
skill_root="/path/to/my-skill", # 可选;用于检测 vendored jiangchang_skill_core
|
||||||
|
)
|
||||||
|
for line in format_runtime_health_lines(diag):
|
||||||
|
print(line)
|
||||||
|
print(runtime_diagnostics_dict(diag)) # 可 JSON 序列化
|
||||||
|
```
|
||||||
|
|
||||||
|
诊断只读探测本地状态,不会触发 media-assets 下载。
|
||||||
|
|
||||||
|
### 录屏合成(`screencast`)
|
||||||
|
|
||||||
|
`run_screencast()` / `compose_video()` 通过 `jiangchang_skill_core.media_assets` 解析 ffmpeg 与背景音乐,**不使用系统 PATH 中的 `ffmpeg`**。
|
||||||
|
|
||||||
|
- **默认**:不传 `media_assets_root` 时,使用 `{JIANGCHANG_DATA_ROOT}/shared/media-assets`(或进程环境中已有的 `MEDIA_ASSETS_ROOT`)。
|
||||||
|
- **覆盖**:传入 `media_assets_root="/path/to/media-assets"`,或在 `compose_video(..., media_assets_env={"MEDIA_ASSETS_ROOT": "..."})` 中设置;参数 `media_assets_root` 优先于 `media_assets_env` 里已有的 `MEDIA_ASSETS_ROOT`。
|
||||||
|
- `run_screencast(media_assets_root=...)` 会把该路径原样传给 `compose_video()`;`music_subdir` 已废弃(会触发 `DeprecationWarning`),音乐选择统一由 `pick_background_music()` 完成。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from screencast import run_screencast
|
||||||
|
|
||||||
|
run_screencast(
|
||||||
|
skill_slug="demo-skill",
|
||||||
|
subtitle_script=[("PASSED", "测试通过")],
|
||||||
|
pytest_args=["-q", "tests/test_demo.py"],
|
||||||
|
output_dir="./out",
|
||||||
|
media_assets_root="/path/to/media-assets", # 可选
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 发版流程(维护者)
|
||||||
|
|
||||||
|
### 发布规则
|
||||||
|
|
||||||
|
- jiangchang-platform-kit **只发布正式版本**。
|
||||||
|
- 每次需要发布公共能力时,先 bump `pyproject.toml` 的 `version`(例如 `1.0.10` → `1.0.11`)。
|
||||||
|
- 推送 `main` 后,`.github/workflows/publish.yml` 会构建并上传该正式版本到 Gitea PyPI。
|
||||||
|
- 发布包版本必须等于 `pyproject.toml` 中的 `version`;若该版本已存在,上传会失败,需继续 bump 版本号后再推送。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
升级安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install --upgrade \
|
||||||
|
--index-url https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/ \
|
||||||
|
--extra-index-url https://pypi.org/simple \
|
||||||
|
jiangchang-platform-kit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装后验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "from jiangchang_desktop_sdk.e2e_helpers import send_prompt_via_composer; print('e2e_helpers OK')"
|
||||||
|
python -c "from screencast import run_screencast; print('screencast OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
仓库内还保留 `examples/`、`tools/`、`python-runtime/` 目录及既有 Skill / 前端相关的 GitHub Actions 工作流,与本 PyPI 包的发布彼此独立。`tools/screencast/` 为兼容薄壳,pip 包内实现位于 `screencast` 包(`src/screencast/`)。
|
||||||
|
|
||||||
|
## v0.2.0 — data-jcid migration (internal refactor)
|
||||||
|
|
||||||
|
### TL;DR
|
||||||
|
|
||||||
|
本次内部重构:SDK 不再依赖匠厂主仓库的 `data-jcid` 属性。
|
||||||
|
所有公开 API(`ask` / `read` / `new_task` / `wait_gateway_ready` 等)
|
||||||
|
**签名与行为保持不变**,只有内部选择器实现切换到新的语义锚点 + ClawX testid。
|
||||||
|
|
||||||
|
### 改变了什么
|
||||||
|
|
||||||
|
- `client.py` 内所有 `[data-jcid="..."]` 选择器已移除
|
||||||
|
- 改用匠厂主仓库为 SDK 承诺的语义锚点:`data-role` / `data-message-id` /
|
||||||
|
`data-streaming`(在 ChatMessage 上)、`data-sending` / `data-message-count`
|
||||||
|
(在 chat-page 根上)
|
||||||
|
- `window.__jc_sending__` 仍是核心 sending 信号,保持不变
|
||||||
|
- 这些锚点由匠厂主仓库的以下机制守护:
|
||||||
|
- `docs/JIANGCHANG_CUSTOM_ANCHORS.md` 中央清单
|
||||||
|
- 源码内 `JIANGCHANG CUSTOM ANCHOR` 边界注释
|
||||||
|
- `harness/specs/jiangchang-custom-anchors.spec.ts` E2E 守护测试
|
||||||
|
- Gitea CI grep 检查 step
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
- `send_file()` 暂未实现,抛 `NotImplementedError`。
|
||||||
|
原因:附件上传走 Electron IPC,SDK 需要主仓库提供新 hook。
|
||||||
|
- `_gateway_state()` 永远返回 `'unknown'`。
|
||||||
|
原因:gateway 状态不再暴露 DOM data-state;本方法已预留未来通过
|
||||||
|
`window.__jc_gateway_state__` 读取的实现路径。
|
||||||
|
|
||||||
|
这两个限制对常规 ask/read 工作流**无影响**,sending 完成判定依靠
|
||||||
|
`window.__jc_sending__` + `data-sending` + 文本稳定性合流即可。
|
||||||
|
|
||||||
|
### 升级影响
|
||||||
|
|
||||||
|
- 在 jiangchang v2.0.17(含)以上版本上跑:应正常工作
|
||||||
|
- 在 jiangchang v2.0.16(及更早含 data-jcid 的版本)上跑:不再兼容
|
||||||
|
如果需要在旧版本上跑,固定 SDK 版本到 v0.1.x
|
||||||
|
|
||||||
|
### 验证清单(集成方建议跑一次)
|
||||||
|
|
||||||
|
打开匠厂客户端 → 用 SDK 跑下面 3 步,确认基础流程通:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jiangchang_desktop_sdk import JiangchangDesktopClient
|
||||||
|
|
||||||
|
with JiangchangDesktopClient() as c:
|
||||||
|
c.ensure_app_running()
|
||||||
|
c.wait_gateway_ready()
|
||||||
|
c.new_task()
|
||||||
|
answer = c.ask("你好,请回复确认 SDK 可用")
|
||||||
|
assert answer, f"Empty answer: {answer!r}"
|
||||||
|
print("OK:", answer[:200])
|
||||||
|
```
|
||||||
|
|||||||
131
STAGE2_SDK_MIGRATION_REPORT.md
Normal file
131
STAGE2_SDK_MIGRATION_REPORT.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Stage 2 — SDK data-jcid Migration Report
|
||||||
|
|
||||||
|
## 1. 执行摘要
|
||||||
|
|
||||||
|
- 起始 HEAD: `089f00c3ff6b75125d77f40b2e2dcb664a9ac5bf`
|
||||||
|
- 结束 HEAD: 本阶段未 commit,工作区状态见下
|
||||||
|
- 影响文件:
|
||||||
|
- `src/jiangchang_desktop_sdk/client.py`(主要改动,7 步合计)
|
||||||
|
- `README.md`(新增 v0.2.0 章节)
|
||||||
|
- `STAGE2_SDK_MIGRATION_REPORT.md`(本回执)
|
||||||
|
- 其他例外文件:无(`types.py` / `exceptions.py` 未改)
|
||||||
|
|
||||||
|
## 2. 7 步改动总览
|
||||||
|
|
||||||
|
| 步 | 内容 | 影响范围 |
|
||||||
|
|----|------|---------|
|
||||||
|
| 1 | SELECTOR 常量表 | client.py 顶部新增 36 行 |
|
||||||
|
| 2 | _get_*_locator x4 + 2 处 raise 文案 | client.py 局部 |
|
||||||
|
| 3 | read() | client.py 局部 |
|
||||||
|
| 4 | 9 个私有 helper | client.py 多处 |
|
||||||
|
| 5 | _wait_for_streaming_done | client.py 局部(docstring + 注释) |
|
||||||
|
| 6 | new_task / wait_gateway_ready / send_file | client.py 局部 |
|
||||||
|
| 7 | README + 扫尾 | README.md + 验证 |
|
||||||
|
|
||||||
|
**第 7 步附带**: `ask()` 兜底分支仍残留可执行 `[data-jcid="message"]` / `data-jcid-role`,已改为 `SEL_MESSAGE_ROW` + `data-role`(否则 7.1 可执行代码 grep 无法通过)。
|
||||||
|
|
||||||
|
## 3. 公开 API 兼容性证明
|
||||||
|
|
||||||
|
逐项列出公开 API 改造前后签名,确认一字不差:
|
||||||
|
|
||||||
|
| 方法 | 改前签名 | 改后签名 | 一致? |
|
||||||
|
|------|---------|---------|-------|
|
||||||
|
| ensure_app_running | (self, wait_timeout_s: float = 30.0) -> None | 同左 | ✓ |
|
||||||
|
| bring_to_front | (self) -> None | 同左 | ✓ |
|
||||||
|
| connect | (self, url: Optional[str] = None) -> None | 同左 | ✓ |
|
||||||
|
| launch_app | (self, options: Optional[LaunchOptions] = None) -> None | 同左 | ✓ |
|
||||||
|
| disconnect | (self) -> None | 同左 | ✓ |
|
||||||
|
| is_connected | (self) -> bool | 同左 | ✓ |
|
||||||
|
| get_page | (self) -> Page | 同左 | ✓ |
|
||||||
|
| new_task | (self) -> None | 同左 | ✓ |
|
||||||
|
| wait_gateway_ready | (self, timeout_ms: int = 30000) -> None | 同左 | ✓ |
|
||||||
|
| wait_for_response | (self, timeout: int = 120000) -> None | 同左 | ✓ |
|
||||||
|
| ask | (self, question: str, options: Optional[AskOptions] = None) -> str | 同左 | ✓ |
|
||||||
|
| send_file | (self, file_path: str, message: Optional[str] = None) -> None | 同左(行为变 NotImplementedError) | ✓ |
|
||||||
|
| read | (self) -> List[JiangchangMessage] | 同左 | ✓ |
|
||||||
|
| assert_contains | (self, expected: str, options: Optional[AssertOptions] = None) -> None | 同左 | ✓ |
|
||||||
|
| snapshot | (self, target_dir: str, tag: str = "snapshot") -> dict | 同左 | ✓ |
|
||||||
|
| __enter__ | (self) -> "JiangchangDesktopClient" | 同左 | ✓ |
|
||||||
|
| __exit__ | (self, exc_type, exc_val, exc_tb) -> None | 同左 | ✓ |
|
||||||
|
|
||||||
|
## 4. 临时限制说明
|
||||||
|
|
||||||
|
### send_file
|
||||||
|
- 状态:抛 NotImplementedError
|
||||||
|
- 原因:Electron IPC stage-paths,SDK 无 DOM 入口
|
||||||
|
- 恢复条件:匠厂主仓库暴露 `window.__jc_stage_paths__` 或带 testid 的隐藏 input
|
||||||
|
|
||||||
|
### _gateway_state
|
||||||
|
- 状态:永远返回 'unknown'
|
||||||
|
- 原因:HEAD 上 gateway 状态不暴露 DOM
|
||||||
|
- 行为影响:_wait_for_streaming_done 中 `if gw not in ("running", "unknown")`
|
||||||
|
实质 no-op,但保留以便未来生效
|
||||||
|
- 恢复条件:匠厂在 main / store 暴露 `window.__jc_gateway_state__`
|
||||||
|
|
||||||
|
## 5. 扫尾验证实际输出
|
||||||
|
|
||||||
|
### 5.1 `grep -n 'data-jcid' src/jiangchang_desktop_sdk/client.py`
|
||||||
|
```
|
||||||
|
src/jiangchang_desktop_sdk/client.py:10:3. 每次提问前新建任务(点击侧栏 `data-jcid="new-task-button"`),避免上下文污染。 (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:14: 这些 DOM 节点 **不再带 `data-jcid="message"`**,而是 `data-testid="chat-execution-graph"` (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:18: · **锚点**:`ask()` 发送后等到最后一条 `[data-jcid-role="user"]` 节点(就是我们刚发的 (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:19: 问题)在 DOM 里出现,记住它在 `[data-jcid="message"]` 列表里的 index,作为切片起点; (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:21: · `chat-root[data-jcid-sending] === 'false'` (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:25: · 我们这条 user 之后存在 `[data-jcid-role="assistant"]` 且其内部有 (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:26: `[data-jcid="message-body"]` (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:29:5. read() 严格按 `data-jcid-role` 分类,没有该属性的 DOM 节点不再默认归为 assistant。 (模块 docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:526: """返回 `[data-jcid="message"]` 节点列表中最后一条 role=user 的 index;找不到返回 -1。""" (docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:561: "streaming": bool, # data-jcid-streaming 属性 (docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:610: """chat-root[data-jcid-sending] 的 bool 化;读不到返回 None。""" (docstring 历史描述)
|
||||||
|
src/jiangchang_desktop_sdk/client.py:796: "我们刚发出的 user 节点在 [data-jcid=\"message\"] 列表里的 index"。后续所有 (ask docstring 历史描述)
|
||||||
|
```
|
||||||
|
|
||||||
|
可执行代码(非 docstring / 非 `#` 注释):**0 命中**。
|
||||||
|
|
||||||
|
### 5.2 `grep -n '__jc_sending__' src/jiangchang_desktop_sdk/client.py`
|
||||||
|
```
|
||||||
|
src/jiangchang_desktop_sdk/client.py:22: · `window.__jc_sending__` !== true
|
||||||
|
src/jiangchang_desktop_sdk/client.py:633: """从 window.__jc_sending__ 读取当前 sending 状态;不可用返回 None。"""
|
||||||
|
src/jiangchang_desktop_sdk/client.py:636: val = page.evaluate("() => window.__jc_sending__")
|
||||||
|
src/jiangchang_desktop_sdk/client.py:673: (C)同时 window.__jc_sending__ 不是 true(允许 None / False,以容忍
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 import 可用性
|
||||||
|
```
|
||||||
|
import OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 集成验证
|
||||||
|
|
||||||
|
- 是否在本地跑过 README 末尾的 3 步验证流程?
|
||||||
|
- [ ] 跑过 → 结果(pass/fail + 关键输出片段)
|
||||||
|
- [x] 未跑 → 原因:验收环境未启动匠厂客户端 / 本阶段仅完成代码与静态扫尾
|
||||||
|
|
||||||
|
- 是否运行了 platform-kit 自带的任何测试?
|
||||||
|
- [ ] 跑过 → 结果
|
||||||
|
- [x] 未跑 → 原因:本包无针对 desktop_sdk 的自动化集成测试;需实机 CDP
|
||||||
|
|
||||||
|
## 7. 未触及的事项
|
||||||
|
|
||||||
|
- `examples/`、`tools/screencast/`:未在本阶段验证;预期不直接依赖已移除的 data-jcid 选择器
|
||||||
|
- platform-kit stash 中的 `runtime_env.py` 改动:本阶段未碰,仍在 stash(`stash@{0}: wip: claw env var fallback...`)
|
||||||
|
- `D:\AI\jiangchang` 主仓库:未修改
|
||||||
|
- 模块级 / 部分 helper / `ask()` docstring 中 data-jcid 历史措辞:保留,见 §9
|
||||||
|
|
||||||
|
## 8. 自检
|
||||||
|
|
||||||
|
- [x] grep 'data-jcid' 在可执行代码中 → 0 命中
|
||||||
|
- [x] 公开 API 签名表逐项核对一致
|
||||||
|
- [x] send_file 抛 NotImplementedError 而非静默
|
||||||
|
- [x] _gateway_state 含 TODO 注释
|
||||||
|
- [x] README 新增 v0.2.0 章节,无格式错乱
|
||||||
|
- [x] 未触碰 D:\AI\jiangchang 主仓库任何文件
|
||||||
|
- [x] 未触碰 platform-kit 非 client.py / README.md / 本回执 的文件
|
||||||
|
- [ ] git status 显示工作区干净(除本阶段预期修改 + 已有 stash) → 见 §1,未 commit
|
||||||
|
|
||||||
|
## 9. 给验收方的备注
|
||||||
|
|
||||||
|
1. **docstring 历史 data-jcid**: 模块顶 docstring(L10–L29)、`_last_user_node_index`(L526)、`_latest_assistant_after` 内联说明(L561)、`_chat_root_sending`(L610)、`ask()` docstring(L796) 仍描述旧 UI 模型;不影响运行。若需文档一致性,可单独开文档清理 PR。
|
||||||
|
2. **send_file**: 调用即 `NotImplementedError`,无连接检查(按规格整段替换方法体)。
|
||||||
|
3. **wait_gateway_ready**: 仅等待 `SEL_CHAT_PAGE` 可见,不保证 Gateway 进程已 ready。
|
||||||
|
4. **建议验收**: 在已安装 v2.0.17+ 匠厂 + Playwright 环境跑 README 末尾脚本;并确认 `send_file(...)` 按预期抛错。
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jiangchang-platform-kit"
|
name = "jiangchang-platform-kit"
|
||||||
version = "0.1.0"
|
version = "1.1.0"
|
||||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -12,6 +12,8 @@ license = "MIT"
|
|||||||
authors = [{ name = "client-jiangchang" }]
|
authors = [{ name = "client-jiangchang" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests>=2.31.0",
|
"requests>=2.31.0",
|
||||||
|
"playwright>=1.42.0",
|
||||||
|
"mss>=9.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
68
sanity_check.py
Normal file
68
sanity_check.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Stage 2 集成验证脚本——一次性 sanity check。
|
||||||
|
|
||||||
|
跑前准备:
|
||||||
|
1. 启动匠厂客户端(确认能正常打开聊天页 + 至少配好一个可用模型)
|
||||||
|
2. 在本仓库激活 Python venv,确认 playwright 已装
|
||||||
|
3. 直接 python sanity_check.py 即可
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Stage 2 SDK Sanity Check")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from jiangchang_desktop_sdk import JiangchangDesktopClient
|
||||||
|
print("[1/6] import OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] import failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with JiangchangDesktopClient() as c:
|
||||||
|
print("[2/6] context entered")
|
||||||
|
|
||||||
|
c.ensure_app_running()
|
||||||
|
print("[3/6] ensure_app_running OK")
|
||||||
|
|
||||||
|
c.wait_gateway_ready()
|
||||||
|
print("[4/6] wait_gateway_ready OK (note: returns even if gw not actually ready)")
|
||||||
|
|
||||||
|
c.new_task()
|
||||||
|
print("[5/6] new_task OK")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
answer = c.ask("你好,请用一句话回复,确认 SDK 可用")
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(f"[6/6] ask() returned in {elapsed:.1f}s")
|
||||||
|
print(f" answer length: {len(answer)} chars")
|
||||||
|
print(f" answer preview: {answer[:200]!r}")
|
||||||
|
assert answer.strip(), "Empty answer!"
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[BONUS] verifying read() works")
|
||||||
|
msgs = c.read()
|
||||||
|
print(f" read() returned {len(msgs)} messages")
|
||||||
|
for i, m in enumerate(msgs[-4:]):
|
||||||
|
print(f" [-{len(msgs)-i}] role={m.role!r} id={m.id!r} "
|
||||||
|
f"content_len={len(m.content) if m.content else 0}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[BONUS] verifying send_file raises NotImplementedError")
|
||||||
|
try:
|
||||||
|
c.send_file("/tmp/fake.txt")
|
||||||
|
print(" [FAIL] send_file did NOT raise!")
|
||||||
|
except NotImplementedError as e:
|
||||||
|
print(f" [OK] send_file raised: {str(e)[:80]}...")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("ALL CHECKS PASSED ✓")
|
||||||
|
print("=" * 60)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[FAIL] sanity check crashed:")
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(2)
|
||||||
@@ -23,7 +23,7 @@ __all__ = [
|
|||||||
"LaunchError",
|
"LaunchError",
|
||||||
"GatewayDownError",
|
"GatewayDownError",
|
||||||
]
|
]
|
||||||
__version__ = "0.1.0"
|
__version__ = "1.0.13"
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
|
|||||||
@@ -54,6 +54,42 @@ from .exceptions import (
|
|||||||
)
|
)
|
||||||
from .types import AskOptions, AssertOptions, JiangchangMessage, LaunchOptions
|
from .types import AskOptions, AssertOptions, JiangchangMessage, LaunchOptions
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# JIANGCHANG DOM SELECTORS
|
||||||
|
# ============================================================================
|
||||||
|
# 本节集中维护所有 Playwright 选择器,作为对匠厂前端的契约。
|
||||||
|
# 选择器分两类:
|
||||||
|
#
|
||||||
|
# (A) 主仓库语义锚点(由 docs/JIANGCHANG_CUSTOM_ANCHORS.md 守护,稳定)
|
||||||
|
# 这些是匠厂为 SDK 暴露的承诺契约,被 E2E 守护测试 + Gitea CI 双重保护。
|
||||||
|
#
|
||||||
|
# (B) 主仓库 ClawX 上游 data-testid(随上游同步可能漂移)
|
||||||
|
# 这些是 ClawX 上游或我们扩展的测试钩子,本身用于内部 E2E,
|
||||||
|
# SDK 借用但不保证不变。
|
||||||
|
#
|
||||||
|
# 修改前请确认 docs/sdk-migration-discovery.md 中对应元素仍存在。
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# --- (A) 匠厂语义锚点 ---
|
||||||
|
SEL_CHAT_PAGE = '[data-testid="chat-page"]'
|
||||||
|
SEL_CHAT_PAGE_SENDING = '[data-testid="chat-page"][data-sending="true"]'
|
||||||
|
SEL_MESSAGE_ROW = '[data-role]' # ChatMessage 行容器
|
||||||
|
SEL_MESSAGE_ROW_USER = '[data-role="user"]'
|
||||||
|
SEL_MESSAGE_ROW_ASSISTANT = '[data-role="assistant"]'
|
||||||
|
SEL_MESSAGE_ROW_STREAMING = '[data-streaming="true"]'
|
||||||
|
|
||||||
|
# --- (B) ClawX/上游 data-testid ---
|
||||||
|
SEL_SIDEBAR_NEW_CHAT = '[data-testid="sidebar-new-chat"]'
|
||||||
|
SEL_CHAT_COMPOSER_INPUT = '[data-testid="chat-composer-input"]'
|
||||||
|
SEL_CHAT_COMPOSER_SEND = '[data-testid="chat-composer-send"]'
|
||||||
|
SEL_CHAT_MESSAGE_INDEXED = '[data-testid^="chat-message-"]' # chat-message-${idx}
|
||||||
|
SEL_EXECUTION_GRAPH = '[data-testid="chat-execution-graph"]'
|
||||||
|
SEL_EXECUTION_GRAPH_EXPANDED = '[data-testid="chat-execution-graph"][data-collapsed="false"]'
|
||||||
|
SEL_MAIN_LAYOUT = '[data-testid="main-layout"]'
|
||||||
|
|
||||||
|
# --- (C) 通用兜底(无 testid 时使用) ---
|
||||||
|
SEL_FALLBACK_TEXTAREA = 'textarea'
|
||||||
|
|
||||||
# ── 调试日志 ───────────────────────────────────────────────────────
|
# ── 调试日志 ───────────────────────────────────────────────────────
|
||||||
_logger = logging.getLogger("jiangchang_desktop_sdk")
|
_logger = logging.getLogger("jiangchang_desktop_sdk")
|
||||||
_handler_added = False
|
_handler_added = False
|
||||||
@@ -182,6 +218,15 @@ class JiangchangDesktopClient:
|
|||||||
_logger.warning("[activate] 协议激活失败:%s", exc)
|
_logger.warning("[activate] 协议激活失败:%s", exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _activate_and_maximize_windows(self) -> bool:
|
||||||
|
"""Windows 专属:把匠厂主窗口拉到桌面最前并最大化。
|
||||||
|
|
||||||
|
实现已抽到 jiangchang_desktop_sdk.window_win32 模块(SDK 子模块),
|
||||||
|
给 screencast 工具也复用。本方法保留为类内入口(兼容旧调用)。
|
||||||
|
"""
|
||||||
|
from .window_win32 import activate_and_maximize_jiangchang_window
|
||||||
|
return activate_and_maximize_jiangchang_window()
|
||||||
|
|
||||||
def _launch_via_executable(self, cdp_port: int) -> bool:
|
def _launch_via_executable(self, cdp_port: int) -> bool:
|
||||||
"""回退:直接 spawn 可执行文件,带 --remote-debugging-port。"""
|
"""回退:直接 spawn 可执行文件,带 --remote-debugging-port。"""
|
||||||
exe = self._get_default_executable_path()
|
exe = self._get_default_executable_path()
|
||||||
@@ -234,12 +279,38 @@ class JiangchangDesktopClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def bring_to_front(self) -> None:
|
def bring_to_front(self) -> None:
|
||||||
"""把主窗口聚焦到最前。"""
|
"""把主窗口聚焦到桌面最前。
|
||||||
|
|
||||||
|
分两层:
|
||||||
|
1) page.bring_to_front() —— Playwright 把 page 切到当前 context 内的最前
|
||||||
|
tab。**不影响 OS 级窗口 z-order**。
|
||||||
|
2) OS 级窗口前置(录屏 / 自动化必需):
|
||||||
|
- Windows: 用 ctypes 调 Win32 EnumWindows 找匠厂主窗口 →
|
||||||
|
ShowWindow(SW_MAXIMIZE) + SetForegroundWindow,让窗口铺满桌面
|
||||||
|
(详见 _activate_and_maximize_windows)。失败时退回协议激活作为
|
||||||
|
兜底,但 dev 模式(pnpm dev / electron .)下协议本身会因 cwd 解析
|
||||||
|
失败而无效,只有安装版协议兜底才可能成功。
|
||||||
|
- macOS / Linux: 走 jiangchang:// 协议(open / xdg-open,
|
||||||
|
非 Windows 系统下协议处理器没有 cwd 解析问题)。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
page.bring_to_front()
|
page.bring_to_front()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.debug("[bring_to_front] 失败:%s", exc)
|
_logger.debug("[bring_to_front] Playwright 切 page 失败:%s", exc)
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
if self._activate_and_maximize_windows():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._activate_via_protocol()
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.debug("[bring_to_front] 协议激活失败:%s", exc)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self._activate_via_protocol()
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.debug("[bring_to_front] 协议激活失败:%s", exc)
|
||||||
|
|
||||||
# ─── 连接 / 断开 ──────────────────────────────────────────────
|
# ─── 连接 / 断开 ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -375,51 +446,46 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
def _get_chat_input_locator(self):
|
def _get_chat_input_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="chat-input"]')
|
locator = page.locator(SEL_CHAT_COMPOSER_INPUT)
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
locator = page.locator("textarea").last
|
locator = page.locator(SEL_FALLBACK_TEXTAREA).last
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
raise ConnectionError("找不到聊天输入框(textarea),请确认匠厂已加 data-jcid=\"chat-input\"")
|
raise ConnectionError(
|
||||||
|
'找不到聊天输入框,请确认匠厂已加 data-testid="chat-composer-input" '
|
||||||
|
"或页面中存在 textarea"
|
||||||
|
)
|
||||||
|
|
||||||
def _get_send_button_locator(self):
|
def _get_send_button_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="send-button"]')
|
locator = page.locator(SEL_CHAT_COMPOSER_SEND)
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
locator = page.locator('button[type="submit"]')
|
locator = page.locator('button[type="submit"]')
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
raise ConnectionError("找不到发送按钮,请确认匠厂已加 data-jcid=\"send-button\"")
|
raise ConnectionError(
|
||||||
|
'找不到发送按钮,请确认匠厂已加 data-testid="chat-composer-send" '
|
||||||
|
'或 button[type="submit"]'
|
||||||
|
)
|
||||||
|
|
||||||
def _get_new_task_button_locator(self):
|
def _get_new_task_button_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="new-task-button"]')
|
locator = page.locator(SEL_SIDEBAR_NEW_CHAT)
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
# 回退:旧版本 data-testid
|
|
||||||
locator = page.locator('[data-testid="sidebar-new-chat"]')
|
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
# 最后兜底:文本"新建任务"
|
|
||||||
locator = page.get_by_text("新建任务", exact=False).first
|
locator = page.get_by_text("新建任务", exact=False).first
|
||||||
return locator
|
return locator
|
||||||
|
|
||||||
def _get_message_list_locator(self):
|
def _get_message_list_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="message-list"]')
|
return page.locator(SEL_CHAT_PAGE)
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
locator = page.locator('[class*="space-y-3"]').first
|
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
raise ConnectionError("找不到消息列表容器 data-jcid=\"message-list\"")
|
|
||||||
|
|
||||||
# ─── 新建任务 ─────────────────────────────────────────────────
|
# ─── 新建任务 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def new_task(self) -> None:
|
def new_task(self) -> None:
|
||||||
"""点击"新建任务"按钮,等待消息列表清空或欢迎屏出现。"""
|
"""点击"新建任务"按钮,等待消息列表清空(chat-page data-message-count=0)。"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
raise ConnectionError("未连接到应用")
|
raise ConnectionError("未连接到应用")
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
@@ -431,16 +497,12 @@ class JiangchangDesktopClient:
|
|||||||
_logger.warning("[new_task] 点击失败:%s", exc)
|
_logger.warning("[new_task] 点击失败:%s", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 等待 chat-root.message-count=0 或 welcome-screen 出现,最多 5s
|
|
||||||
deadline = time.time() + 5
|
deadline = time.time() + 5
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
count = page.locator('[data-jcid="chat-root"]').first.get_attribute("data-jcid-message-count")
|
count = page.locator(SEL_CHAT_PAGE).first.get_attribute("data-message-count")
|
||||||
if count == "0":
|
if count == "0":
|
||||||
_logger.debug("[new_task] 已进入新任务(message-count=0)")
|
_logger.debug("[new_task] 已进入新任务(data-message-count=0)")
|
||||||
return
|
|
||||||
if page.locator('[data-jcid="welcome-screen"]').count() > 0:
|
|
||||||
_logger.debug("[new_task] 欢迎屏已出现")
|
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -450,25 +512,35 @@ class JiangchangDesktopClient:
|
|||||||
# ─── 网关健康检查 ─────────────────────────────────────────────
|
# ─── 网关健康检查 ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _gateway_state(self) -> str:
|
def _gateway_state(self) -> str:
|
||||||
|
"""Read gateway state via Electron IPC bridge (jiangchang exposes no DOM data-state).
|
||||||
|
|
||||||
|
Strategy: try to read window.__jc_gateway_state__ if exposed;
|
||||||
|
otherwise return 'unknown' and let upper logic continue.
|
||||||
|
"""
|
||||||
|
# TODO: 等匠厂在 src/stores/gateway/* 或 main 进程暴露
|
||||||
|
# window.__jc_gateway_state__ 之后,本方法将自动开始返回真实状态。
|
||||||
|
# 当前永远返回 'unknown',行为退化为"假定 gateway 健康"。
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
state = self.get_page().evaluate(
|
||||||
el = page.locator('[data-jcid="gateway-status"]').first
|
"() => window.__jc_gateway_state__"
|
||||||
if el.count() == 0:
|
)
|
||||||
return "unknown"
|
if isinstance(state, str) and state:
|
||||||
return el.get_attribute("data-state") or "unknown"
|
return state
|
||||||
except Exception:
|
except Exception:
|
||||||
return "unknown"
|
pass
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
def wait_gateway_ready(self, timeout_ms: int = 30000) -> None:
|
def wait_gateway_ready(self, timeout_ms: int = 30000) -> None:
|
||||||
|
"""等待匠厂前端就绪。
|
||||||
|
|
||||||
|
由于 v2.0.17 起 gateway 状态不再暴露 DOM data-state,本方法降级为
|
||||||
|
等待 chat-page 根元素可见(隐含 React + gateway store 已初始化)。
|
||||||
|
Gateway 实际就绪与否,由后续 ask() 内的 _gateway_state 检查兜底
|
||||||
|
(当前永远返回 'unknown')。
|
||||||
|
"""
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
try:
|
try:
|
||||||
page.wait_for_function(
|
page.wait_for_selector(SEL_CHAT_PAGE, timeout=timeout_ms)
|
||||||
"""() => {
|
|
||||||
const el = document.querySelector('[data-jcid="gateway-status"]');
|
|
||||||
return el && el.dataset.state === 'running';
|
|
||||||
}""",
|
|
||||||
timeout=timeout_ms,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.warning("[wait_gateway_ready] 超时/异常:%s(继续,但后续可能报 GatewayDown)", exc)
|
_logger.warning("[wait_gateway_ready] 超时/异常:%s(继续,但后续可能报 GatewayDown)", exc)
|
||||||
|
|
||||||
@@ -481,7 +553,7 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
def _message_node_count(self) -> int:
|
def _message_node_count(self) -> int:
|
||||||
try:
|
try:
|
||||||
return self.get_page().locator('[data-jcid="message"]').count()
|
return self.get_page().locator(SEL_MESSAGE_ROW).count()
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -489,10 +561,10 @@ class JiangchangDesktopClient:
|
|||||||
"""返回 `[data-jcid="message"]` 节点列表中最后一条 role=user 的 index;找不到返回 -1。"""
|
"""返回 `[data-jcid="message"]` 节点列表中最后一条 role=user 的 index;找不到返回 -1。"""
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
total = page.locator('[data-jcid="message"]').count()
|
total = page.locator(SEL_MESSAGE_ROW).count()
|
||||||
for i in range(total - 1, -1, -1):
|
for i in range(total - 1, -1, -1):
|
||||||
node = page.locator('[data-jcid="message"]').nth(i)
|
node = page.locator(SEL_MESSAGE_ROW).nth(i)
|
||||||
role = node.get_attribute("data-jcid-role") or ""
|
role = node.get_attribute("data-role") or ""
|
||||||
if role.lower() == "user":
|
if role.lower() == "user":
|
||||||
return i
|
return i
|
||||||
return -1
|
return -1
|
||||||
@@ -507,7 +579,7 @@ class JiangchangDesktopClient:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
node = page.locator('[data-jcid="message"]').nth(user_idx)
|
node = page.locator(SEL_MESSAGE_ROW).nth(user_idx)
|
||||||
text = node.inner_text(timeout=2000) or ""
|
text = node.inner_text(timeout=2000) or ""
|
||||||
t = re.sub(r"\s+", "", text)
|
t = re.sub(r"\s+", "", text)
|
||||||
q = re.sub(r"\s+", "", question)
|
q = re.sub(r"\s+", "", question)
|
||||||
@@ -529,22 +601,19 @@ class JiangchangDesktopClient:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
nodes = page.locator('[data-jcid="message"]')
|
nodes = page.locator(SEL_MESSAGE_ROW)
|
||||||
total = nodes.count()
|
total = nodes.count()
|
||||||
for i in range(total - 1, user_idx, -1):
|
for i in range(total - 1, user_idx, -1):
|
||||||
node = nodes.nth(i)
|
node = nodes.nth(i)
|
||||||
role = (node.get_attribute("data-jcid-role") or "").lower()
|
role = (node.get_attribute("data-role") or "").lower()
|
||||||
if role != "assistant":
|
if role != "assistant":
|
||||||
continue
|
continue
|
||||||
body = node.locator('[data-jcid="message-body"]')
|
try:
|
||||||
has_body = body.count() > 0
|
body_text = node.inner_text(timeout=2000) or ""
|
||||||
body_text = ""
|
except Exception:
|
||||||
if has_body:
|
body_text = ""
|
||||||
try:
|
has_body = len(body_text.strip()) > 0
|
||||||
body_text = body.first.inner_text(timeout=2000) or ""
|
streaming = (node.get_attribute("data-streaming") or "false").lower() == "true"
|
||||||
except Exception:
|
|
||||||
body_text = ""
|
|
||||||
streaming = (node.get_attribute("data-jcid-streaming") or "false").lower() == "true"
|
|
||||||
return {
|
return {
|
||||||
"index": i,
|
"index": i,
|
||||||
"has_body": has_body,
|
"has_body": has_body,
|
||||||
@@ -575,8 +644,8 @@ class JiangchangDesktopClient:
|
|||||||
def _chat_root_sending(self) -> Optional[bool]:
|
def _chat_root_sending(self) -> Optional[bool]:
|
||||||
"""chat-root[data-jcid-sending] 的 bool 化;读不到返回 None。"""
|
"""chat-root[data-jcid-sending] 的 bool 化;读不到返回 None。"""
|
||||||
try:
|
try:
|
||||||
val = self.get_page().locator('[data-jcid="chat-root"]').first.get_attribute(
|
val = self.get_page().locator(SEL_CHAT_PAGE).first.get_attribute(
|
||||||
"data-jcid-sending"
|
"data-sending"
|
||||||
)
|
)
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
@@ -586,8 +655,8 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
def _chat_root_message_count(self) -> Optional[int]:
|
def _chat_root_message_count(self) -> Optional[int]:
|
||||||
try:
|
try:
|
||||||
val = self.get_page().locator('[data-jcid="chat-root"]').first.get_attribute(
|
val = self.get_page().locator(SEL_CHAT_PAGE).first.get_attribute(
|
||||||
"data-jcid-message-count"
|
"data-message-count"
|
||||||
)
|
)
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
@@ -626,16 +695,16 @@ class JiangchangDesktopClient:
|
|||||||
"""
|
"""
|
||||||
等待 agent 整轮(可能包含多次 thinking / tool_use 往返)完成。
|
等待 agent 整轮(可能包含多次 thinking / tool_use 往返)完成。
|
||||||
|
|
||||||
核心判据 —— 只有 agent 最终答复才有 `message-body`(文本气泡):
|
核心判据 —— agent 最终答复在 data-role=assistant 行上有非空 inner_text:
|
||||||
源码依据(D:\\AI\\jiangchang/src/stores/chat/helpers.ts):
|
源码依据(D:\\AI\\jiangchang/src/stores/chat/helpers.ts):
|
||||||
- isToolOnlyMessage 显式允许 thinking 与 tool_use 共存仍视为中间轮
|
- isToolOnlyMessage 显式允许 thinking 与 tool_use 共存仍视为中间轮
|
||||||
- 中间工具轮的 ChatMessage 无 hasText → 不渲染 MessageBubble →
|
- 中间工具轮可能无可见正文气泡 → data-role 行 inner_text 为空
|
||||||
也就不会有 `data-jcid="message-body"`
|
- 只有最终含 text 的助手消息行 inner_text 非空(has_body)
|
||||||
- 只有最终含 text 的助手消息才会渲染 message-body
|
|
||||||
|
|
||||||
因此可靠的完成条件为:
|
因此可靠的完成条件为:
|
||||||
(A)最后一条 assistant message 存在 [data-jcid="message-body"];
|
(A)user_msg_index 之后最后一条 assistant 的 has_body 为 True
|
||||||
(B)其文本连续 `stable_seconds` 秒无变化;
|
(由 _latest_assistant_after 根据 inner_text 判定);
|
||||||
|
(B)其 body_text 连续 `stable_seconds` 秒无变化;
|
||||||
(C)同时 window.__jc_sending__ 不是 true(允许 None / False,以容忍
|
(C)同时 window.__jc_sending__ 不是 true(允许 None / False,以容忍
|
||||||
早期 React 未挂载注入钩子的情况)。
|
早期 React 未挂载注入钩子的情况)。
|
||||||
|
|
||||||
@@ -675,10 +744,10 @@ class JiangchangDesktopClient:
|
|||||||
assistant = self._latest_assistant_after(user_msg_index)
|
assistant = self._latest_assistant_after(user_msg_index)
|
||||||
|
|
||||||
# 完成判据需同时满足:
|
# 完成判据需同时满足:
|
||||||
# (1) 三路 sending 信号全部静默(chat-root / window / 未收起的 execution graph)
|
# (1) 三路 sending 信号全部静默(chat-page data-sending / window / 未收起的 execution graph)
|
||||||
# (2) user_msg_index 之后存在 assistant 消息
|
# (2) user_msg_index 之后存在 assistant 消息(data-role 行)
|
||||||
# (3) 该 assistant 存在 message-body
|
# (3) 该 assistant has_body 为 True(_latest_assistant_after,inner_text 非空)
|
||||||
# (4) 该 assistant 的 data-jcid-streaming != 'true'
|
# (4) 该 assistant 的 data-streaming != 'true'
|
||||||
# (5) body 文本连续 stable_seconds 秒无变化
|
# (5) body 文本连续 stable_seconds 秒无变化
|
||||||
can_check_stable = (
|
can_check_stable = (
|
||||||
not sending_any
|
not sending_any
|
||||||
@@ -740,7 +809,7 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
raise JcTimeoutError(
|
raise JcTimeoutError(
|
||||||
f"等待 AI 回复超时({timeout_ms}ms)。user_msg_index={user_msg_index} 最后阶段 phase={phase}。"
|
f"等待 AI 回复超时({timeout_ms}ms)。user_msg_index={user_msg_index} 最后阶段 phase={phase}。"
|
||||||
"可能原因:模型思考时间过长 / 工具链循环未结束 / UI 未挂载 message-body / Gateway 卡住。"
|
"可能原因:模型思考时间过长 / 工具链循环未结束 / assistant 正文未出现(data-role 行 inner_text 为空)/ Gateway 卡住。"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─── 核心交互:ask / read / assert ────────────────────────────
|
# ─── 核心交互:ask / read / assert ────────────────────────────
|
||||||
@@ -844,70 +913,56 @@ class JiangchangDesktopClient:
|
|||||||
poll_interval=opts.poll_interval,
|
poll_interval=opts.poll_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. 选答:只在 my_user_idx 之后的 assistant 中找带 message-body 的最后一条
|
# 6. 选答:只在 my_user_idx 之后的 assistant 中找 has_body 的最后一条
|
||||||
picked = self._latest_assistant_after(my_user_idx)
|
picked = self._latest_assistant_after(my_user_idx)
|
||||||
if picked is None or not picked["has_body"]:
|
if picked is None or not picked["has_body"]:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"[ask] user_idx=%d 之后未找到带 message-body 的 assistant(picked=%s)",
|
"[ask] user_idx=%d 之后未找到 has_body 的 assistant(picked=%s)",
|
||||||
my_user_idx, picked,
|
my_user_idx, picked,
|
||||||
)
|
)
|
||||||
# 兜底:取我们 user 之后的任意最后一条 assistant inner_text
|
# 兜底:取我们 user 之后的任意最后一条 assistant inner_text
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
nodes = page.locator('[data-jcid="message"]')
|
nodes = page.locator(SEL_MESSAGE_ROW)
|
||||||
total = nodes.count()
|
total = nodes.count()
|
||||||
for i in range(total - 1, my_user_idx, -1):
|
for i in range(total - 1, my_user_idx, -1):
|
||||||
node = nodes.nth(i)
|
node = nodes.nth(i)
|
||||||
if (node.get_attribute("data-jcid-role") or "").lower() == "assistant":
|
if (node.get_attribute("data-role") or "").lower() == "assistant":
|
||||||
return node.inner_text(timeout=2000) or ""
|
return node.inner_text(timeout=2000) or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
"[ask] 选定 assistant_idx=%d(user_idx=%d 之后第一条带 message-body)len=%d",
|
"[ask] 选定 assistant_idx=%d(user_idx=%d 之后 has_body)len=%d",
|
||||||
picked["index"], my_user_idx, len(picked["body_text"]),
|
picked["index"], my_user_idx, len(picked["body_text"]),
|
||||||
)
|
)
|
||||||
return picked["body_text"]
|
return picked["body_text"]
|
||||||
|
|
||||||
def send_file(self, file_path: str, message: Optional[str] = None) -> None:
|
def send_file(self, file_path: str, message: Optional[str] = None) -> None:
|
||||||
if not self._connected:
|
"""[暂未实现] 发送文件附件。
|
||||||
raise ConnectionError("未连接到应用")
|
|
||||||
page = self.get_page()
|
|
||||||
|
|
||||||
attach_btn = page.locator('[data-jcid="attach-button"]')
|
匠厂 v2.0.17(ClawX v0.4.3 合并后)的附件上传通道由原生 Electron
|
||||||
if attach_btn.count() == 0:
|
showOpenDialog + IPC stage-paths 实现,不再暴露 HTML <input type="file">。
|
||||||
attach_btn = page.locator("button").filter(
|
|
||||||
has_text=re.compile(r"附件|上传|paperclip|attach", re.I)
|
|
||||||
)
|
|
||||||
|
|
||||||
file_input = page.locator('input[type="file"]')
|
Playwright 无法直接触发 Electron 原生对话框。需要匠厂主仓库新增:
|
||||||
if file_input.count() == 0 and attach_btn.count() > 0:
|
- window.__jc_stage_paths__(paths: string[]) 调用桥,或
|
||||||
attach_btn.first.click()
|
- 一个隐藏 <input type="file"> 备用通道,带 data-testid
|
||||||
time.sleep(0.5)
|
|
||||||
file_input = page.locator('input[type="file"]')
|
|
||||||
|
|
||||||
if file_input.count() == 0:
|
在匠厂提供其中之一之前,本方法抛 NotImplementedError 而非静默成功。
|
||||||
raise AssertError(
|
|
||||||
"找不到文件上传 input[type='file']。请确认匠厂已加 data-jcid=\"attach-button\"。"
|
|
||||||
)
|
|
||||||
|
|
||||||
abs_path = os.path.abspath(file_path)
|
Tracking: 见 docs/sdk-migration-discovery.md §7
|
||||||
if not os.path.exists(abs_path):
|
"""
|
||||||
raise AppNotFoundError(f"上传文件不存在: {abs_path}")
|
raise NotImplementedError(
|
||||||
|
"send_file is temporarily unavailable on jiangchang v2.0.17+. "
|
||||||
file_input.set_input_files(abs_path)
|
"Reason: attachments now go through Electron IPC; SDK requires "
|
||||||
time.sleep(1)
|
"a new DOM-or-window hook from the host application. "
|
||||||
|
"See docs/sdk-migration-discovery.md §7."
|
||||||
if message:
|
)
|
||||||
input_locator = self._get_chat_input_locator()
|
|
||||||
input_locator.click()
|
|
||||||
input_locator.press_sequentially(message, delay=25)
|
|
||||||
input_locator.press("Enter")
|
|
||||||
|
|
||||||
def read(self) -> List[JiangchangMessage]:
|
def read(self) -> List[JiangchangMessage]:
|
||||||
"""
|
"""
|
||||||
严格读取:仅接受带 data-jcid="message" + data-jcid-role 的节点。
|
严格读取:仅接受带 data-role 语义锚点的 ChatMessage 行节点。
|
||||||
无 role 的节点返回 role='unknown'(不再默认为 assistant)。
|
无 role 的节点返回 role='unknown'(不再默认为 assistant)。
|
||||||
"""
|
"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
@@ -917,41 +972,35 @@ class JiangchangDesktopClient:
|
|||||||
messages: List[JiangchangMessage] = []
|
messages: List[JiangchangMessage] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg_list = self._get_message_list_locator()
|
page.locator(SEL_CHAT_PAGE).wait_for(state="visible", timeout=5000)
|
||||||
msg_list.wait_for(state="visible", timeout=5000)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.debug("[read] 消息列表不可见:%s", exc)
|
_logger.debug("[read] 聊天页不可见:%s", exc)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
items = page.locator('[data-jcid="message"]')
|
items = page.locator(SEL_MESSAGE_ROW)
|
||||||
total = items.count()
|
total = items.count()
|
||||||
_logger.debug("[read] data-jcid=\"message\" 节点数=%d", total)
|
_logger.debug("[read] data-role 消息行数=%d", total)
|
||||||
|
|
||||||
for i in range(total):
|
for i in range(total):
|
||||||
el = items.nth(i)
|
el = items.nth(i)
|
||||||
try:
|
try:
|
||||||
role_attr = el.get_attribute("data-jcid-role")
|
role_attr = el.get_attribute("data-role")
|
||||||
streaming_attr = el.get_attribute("data-jcid-streaming") or "false"
|
streaming_attr = el.get_attribute("data-streaming") or "false"
|
||||||
# 优先从 message-body(正文气泡)取干净文本,避免带入 Thinking / ToolCards
|
content = el.inner_text(timeout=2000)
|
||||||
body_locator = el.locator('[data-jcid="message-body"]')
|
|
||||||
if body_locator.count() > 0:
|
|
||||||
content = body_locator.first.inner_text(timeout=2000)
|
|
||||||
else:
|
|
||||||
content = el.inner_text(timeout=2000)
|
|
||||||
|
|
||||||
role = (role_attr or "unknown").lower()
|
role = (role_attr or "unknown").lower()
|
||||||
|
tool_status = "streaming" if streaming_attr == "true" else None
|
||||||
messages.append(
|
messages.append(
|
||||||
JiangchangMessage(
|
JiangchangMessage(
|
||||||
id=el.get_attribute("data-jcid-message-id") or f"msg-{i}",
|
id=el.get_attribute("data-message-id") or f"msg-{i}",
|
||||||
role=role,
|
role=role,
|
||||||
content=content,
|
content=content,
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
is_error=(el.get_attribute("data-jcid-error") == "true"),
|
is_error=False,
|
||||||
tool_name=el.get_attribute("data-jcid-tool"),
|
tool_name=None,
|
||||||
|
tool_status=tool_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if streaming_attr == "true":
|
|
||||||
messages[-1].tool_status = "streaming"
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.debug("[read] 读取第%d条异常:%s", i, exc)
|
_logger.debug("[read] 读取第%d条异常:%s", i, exc)
|
||||||
continue
|
continue
|
||||||
|
|||||||
470
src/jiangchang_desktop_sdk/e2e_helpers.py
Normal file
470
src/jiangchang_desktop_sdk/e2e_helpers.py
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
桌面 E2E 通用 helper:所有 skill 的 desktop E2E 测试共用。
|
||||||
|
|
||||||
|
为什么独立出来:
|
||||||
|
- disburse-payroll-icbc 实战验证:SDK 的 ask() 在 user_idx<0 时有死循环 bug,
|
||||||
|
且 send_file() 是 NotImplementedError。需要绕开它们自写完整测试流程
|
||||||
|
- 这些 helper(拖拽附件、等 user 节点、多信号合流等待 agent 完成)是
|
||||||
|
**所有 skill 都通用的**,不应该每个 skill 都内嵌一份
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
- 所有 helper 只接受 Playwright Page 作为入口(不依赖 SDK client 实例)
|
||||||
|
- 时间常量默认值留出宽容(user message 60s / agent complete 1200s)
|
||||||
|
- 失败一律抛 TimeoutError 或 AssertionError,由调用方决定怎么截屏/上报
|
||||||
|
|
||||||
|
依赖匠厂 DOM 锚点(docs/JIANGCHANG_CUSTOM_ANCHORS.md 守护):
|
||||||
|
- [data-testid="chat-composer-input"]
|
||||||
|
- [data-testid="chat-page"][data-sending]
|
||||||
|
- [data-role="user"] / [data-role="assistant"]
|
||||||
|
- [data-testid="chat-execution-graph"][data-collapsed]
|
||||||
|
- window.__jc_sending__
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Optional, Tuple
|
||||||
|
|
||||||
|
from playwright.sync_api import Page
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SkillDataDir",
|
||||||
|
"resolve_skill_data_dir",
|
||||||
|
"require_logged_in_skill_data_dir",
|
||||||
|
"get_runtime_env",
|
||||||
|
"assert_skill_env_file",
|
||||||
|
"drop_file_into_composer",
|
||||||
|
"wait_for_attachment_ready",
|
||||||
|
"wait_for_composer_enabled",
|
||||||
|
"send_prompt_via_composer",
|
||||||
|
"wait_for_user_message",
|
||||||
|
"wait_for_agent_complete",
|
||||||
|
]
|
||||||
|
|
||||||
|
_NEWLINE = object()
|
||||||
|
|
||||||
|
_ANONYMOUS_USER_IDS = frozenset({"", "_anon", "anon", "anonymous"})
|
||||||
|
|
||||||
|
_DEFAULT_PLACEHOLDER_VALUES = frozenset({
|
||||||
|
"",
|
||||||
|
"xxx",
|
||||||
|
"sk-xxx",
|
||||||
|
"your-key",
|
||||||
|
"your_api_key",
|
||||||
|
"todo",
|
||||||
|
"changeme",
|
||||||
|
"replace-me",
|
||||||
|
"<your-key>",
|
||||||
|
})
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkillDataDir:
|
||||||
|
"""匠厂宿主解析出的 skill 用户数据目录上下文。"""
|
||||||
|
|
||||||
|
root: Path
|
||||||
|
path: Path
|
||||||
|
user_id: str
|
||||||
|
skill_slug: str
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ws(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", "", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _invoke_ipc(page: Page, channel: str, payload: dict | None = None) -> dict:
|
||||||
|
"""通过匠厂 preload 暴露的 IPC 调用主进程 handler。"""
|
||||||
|
result = page.evaluate(
|
||||||
|
"""
|
||||||
|
async ({ channel, payload }) => {
|
||||||
|
const electron = window.electron;
|
||||||
|
if (!electron || !electron.ipcRenderer
|
||||||
|
|| typeof electron.ipcRenderer.invoke !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'window.electron.ipcRenderer.invoke 不可用:请在匠厂桌面客户端页面内运行 E2E,'
|
||||||
|
+ '且 preload 已暴露 jiangchang IPC'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (payload === null || payload === undefined) {
|
||||||
|
return await electron.ipcRenderer.invoke(channel);
|
||||||
|
}
|
||||||
|
return await electron.ipcRenderer.invoke(channel, payload);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err && err.message ? err.message : String(err);
|
||||||
|
throw new Error(channel + ' IPC 调用失败: ' + msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
{"channel": channel, "payload": payload},
|
||||||
|
)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{channel} 返回非对象: {result!r}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_skill_data_dir(
|
||||||
|
page: Page,
|
||||||
|
skill_slug: str,
|
||||||
|
*,
|
||||||
|
create: bool = True,
|
||||||
|
) -> SkillDataDir:
|
||||||
|
"""通过宿主 IPC 解析当前登录用户下的 skill 数据目录(不拼路径、不用 _anon 默认值)。"""
|
||||||
|
raw = _invoke_ipc(
|
||||||
|
page,
|
||||||
|
"jiangchang:resolve-skill-data-dir",
|
||||||
|
{"skillSlug": skill_slug},
|
||||||
|
)
|
||||||
|
missing = [k for k in ("root", "path", "userId", "skillSlug") if not raw.get(k)]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"jiangchang:resolve-skill-data-dir 返回结构不完整,"
|
||||||
|
f"缺少字段 {missing},实际: {raw!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data_path = Path(str(raw["path"]))
|
||||||
|
if create:
|
||||||
|
data_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return SkillDataDir(
|
||||||
|
root=Path(str(raw["root"])),
|
||||||
|
path=data_path,
|
||||||
|
user_id=str(raw["userId"]),
|
||||||
|
skill_slug=str(raw["skillSlug"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_logged_in_skill_data_dir(page: Page, skill_slug: str) -> SkillDataDir:
|
||||||
|
"""解析 skill 数据目录,并断言当前宿主已同步真实登录用户(非匿名)。"""
|
||||||
|
ctx = resolve_skill_data_dir(page, skill_slug)
|
||||||
|
uid = (ctx.user_id or "").strip().lower()
|
||||||
|
if uid in _ANONYMOUS_USER_IDS:
|
||||||
|
raise AssertionError(
|
||||||
|
"当前宿主没有同步登录用户 ID(收到 "
|
||||||
|
f"{ctx.user_id!r}),不能运行桌面 E2E。"
|
||||||
|
"请先在匠厂客户端登录,或检查 jiangchangUserId 是否已同步到设置。"
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_env(page: Page) -> dict:
|
||||||
|
"""诊断用:读取宿主主进程解析的运行时环境(勿用于拼路径)。"""
|
||||||
|
return _invoke_ipc(page, "jiangchang:get-runtime-env", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_env_value(raw: str) -> str:
|
||||||
|
value = raw.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_file(env_path: Path) -> dict[str, str]:
|
||||||
|
"""解析简单 KEY=value .env(无 shell 展开)。"""
|
||||||
|
parsed: dict[str, str] = {}
|
||||||
|
text = env_path.read_text(encoding="utf-8")
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in stripped:
|
||||||
|
continue
|
||||||
|
key, _, raw_value = stripped.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
parsed[key] = _strip_env_value(raw_value)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _is_placeholder_value(value: str, extra: set[str] | None) -> bool:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
placeholders = _DEFAULT_PLACEHOLDER_VALUES
|
||||||
|
if extra:
|
||||||
|
placeholders = placeholders | {p.strip().lower() for p in extra}
|
||||||
|
return normalized in placeholders
|
||||||
|
|
||||||
|
|
||||||
|
def assert_skill_env_file(
|
||||||
|
skill_data_dir: SkillDataDir,
|
||||||
|
required_keys: list[str] | tuple[str, ...],
|
||||||
|
*,
|
||||||
|
env_rel_path: str = "config/.env",
|
||||||
|
placeholder_values: set[str] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Preflight:检查 skill 用户数据目录下 config/.env 是否已配置所需密钥。"""
|
||||||
|
env_path = skill_data_dir.path / env_rel_path
|
||||||
|
if not env_path.is_file():
|
||||||
|
raise AssertionError(
|
||||||
|
f"缺少配置文件: {env_path}\n"
|
||||||
|
"E2E 测试不会自动生成真实密钥,请先一次性配置用户数据目录 config/.env"
|
||||||
|
)
|
||||||
|
|
||||||
|
env_map = _parse_env_file(env_path)
|
||||||
|
invalid: list[str] = []
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in env_map:
|
||||||
|
invalid.append(key)
|
||||||
|
continue
|
||||||
|
value = env_map[key]
|
||||||
|
if not value.strip() or _is_placeholder_value(value, placeholder_values):
|
||||||
|
invalid.append(key)
|
||||||
|
|
||||||
|
if invalid:
|
||||||
|
raise AssertionError(
|
||||||
|
f"config/.env 配置不完整或仍为占位符: {env_path}\n"
|
||||||
|
f"缺失/无效的 key: {', '.join(invalid)}\n"
|
||||||
|
"E2E 测试不会自动生成真实密钥,请先一次性配置用户数据目录 config/.env"
|
||||||
|
)
|
||||||
|
return env_path
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_text_and_newlines(prompt: str) -> Iterator[str | object]:
|
||||||
|
"""按真实输入顺序产出文本片段与换行标记(\\r\\n / \\n / \\r 均视为单次换行)。"""
|
||||||
|
i = 0
|
||||||
|
n = len(prompt)
|
||||||
|
text_start = 0
|
||||||
|
while i < n:
|
||||||
|
if i + 1 < n and prompt[i] == "\r" and prompt[i + 1] == "\n":
|
||||||
|
if i > text_start:
|
||||||
|
yield prompt[text_start:i]
|
||||||
|
yield _NEWLINE
|
||||||
|
i += 2
|
||||||
|
text_start = i
|
||||||
|
elif prompt[i] in "\n\r":
|
||||||
|
if i > text_start:
|
||||||
|
yield prompt[text_start:i]
|
||||||
|
yield _NEWLINE
|
||||||
|
i += 1
|
||||||
|
text_start = i
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
if text_start < n:
|
||||||
|
yield prompt[text_start:]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 附件上传(绕开 SDK send_file 未实现) ──────────────────────────
|
||||||
|
|
||||||
|
def drop_file_into_composer(page: Page, file_path: Path) -> None:
|
||||||
|
"""通过 DragEvent + DataTransfer 把文件拖入聊天输入框。
|
||||||
|
|
||||||
|
匠厂 ChatInput 的 onDrop 挂在外层 div 上,但 React 17+ 事件代理到 root,
|
||||||
|
所以在 textarea 上派发 bubbles:true 的 drop 事件能正常触发 stageBufferFiles
|
||||||
|
→ /api/files/stage-buffer → 真实附件链路。
|
||||||
|
"""
|
||||||
|
payload_b64 = base64.b64encode(file_path.read_bytes()).decode("ascii")
|
||||||
|
page.evaluate(
|
||||||
|
"""
|
||||||
|
({ base64, fileName, mimeType }) => {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const file = new File([bytes], fileName, { type: mimeType });
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
const textarea = document.querySelector('[data-testid="chat-composer-input"]');
|
||||||
|
if (!textarea) {
|
||||||
|
throw new Error('chat-composer-input not found');
|
||||||
|
}
|
||||||
|
const opts = { bubbles: true, cancelable: true, dataTransfer: dt };
|
||||||
|
textarea.dispatchEvent(new DragEvent('dragenter', opts));
|
||||||
|
textarea.dispatchEvent(new DragEvent('dragover', opts));
|
||||||
|
textarea.dispatchEvent(new DragEvent('drop', opts));
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"base64": payload_b64,
|
||||||
|
"fileName": file_path.name,
|
||||||
|
"mimeType": _guess_mime(file_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_mime(file_path: Path) -> str:
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
return {
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".json": "application/json",
|
||||||
|
}.get(suffix, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_attachment_ready(page: Page, file_name: str, timeout_s: float = 30.0) -> None:
|
||||||
|
"""stage-buffer 成功后附件 chip 会展示 fileName,轮询直到可见。"""
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
name_loc = page.get_by_text(file_name, exact=False)
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
n = name_loc.count()
|
||||||
|
for i in range(n):
|
||||||
|
if name_loc.nth(i).is_visible():
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"附件 {file_name!r} 在 {timeout_s}s 内未变为 ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 发送 prompt(绕开 SDK ask 的 15s 锁死) ────────────────────────
|
||||||
|
|
||||||
|
def wait_for_composer_enabled(page: Page, timeout_s: float = 15.0) -> None:
|
||||||
|
"""等输入框可见且非 disabled(避免 canSend=false 静默 no-op)。"""
|
||||||
|
page.wait_for_selector(
|
||||||
|
'[data-testid="chat-composer-input"]',
|
||||||
|
state="visible",
|
||||||
|
timeout=int(timeout_s * 1000),
|
||||||
|
)
|
||||||
|
textarea = page.locator('[data-testid="chat-composer-input"]')
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
if not textarea.evaluate("(el) => el.disabled"):
|
||||||
|
return
|
||||||
|
time.sleep(0.3)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"chat-composer-input 在 {timeout_s}s 内仍为 disabled(gateway 可能未就绪)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_prompt_via_composer(page: Page, prompt: str, *, delay_ms: int = 25) -> None:
|
||||||
|
"""拟人逐字输入 + Shift+Enter 换行 + Enter 发送(禁止 DOM/剪贴板/伪造事件)。"""
|
||||||
|
textarea = page.locator('[data-testid="chat-composer-input"]')
|
||||||
|
textarea.click()
|
||||||
|
textarea.press("Control+A")
|
||||||
|
textarea.press("Backspace")
|
||||||
|
for segment in _iter_text_and_newlines(prompt):
|
||||||
|
if segment is _NEWLINE:
|
||||||
|
textarea.press("Shift+Enter")
|
||||||
|
else:
|
||||||
|
textarea.press_sequentially(str(segment), delay=delay_ms)
|
||||||
|
textarea.press("Enter")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_user_message(page: Page, prompt: str, timeout_s: float = 60.0) -> int:
|
||||||
|
"""等我们这条 user 消息进入 DOM,返回它在 [data-role] 列表里的 index。
|
||||||
|
|
||||||
|
绕开 SDK ask() 只给 15s 窗口的脆弱判据:用 prompt 文本反向匹配,
|
||||||
|
宽容到 60s。脏 session(new_task 后 gateway 载回历史)也能正确定位。
|
||||||
|
"""
|
||||||
|
prompt_norm = _normalize_ws(prompt)
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
items = page.locator('[data-role]')
|
||||||
|
while time.time() < deadline:
|
||||||
|
total = items.count()
|
||||||
|
for i in range(total - 1, -1, -1):
|
||||||
|
node = items.nth(i)
|
||||||
|
role = (node.get_attribute("data-role") or "").lower()
|
||||||
|
if role != "user":
|
||||||
|
continue
|
||||||
|
text = node.inner_text(timeout=2000) or ""
|
||||||
|
text_norm = _normalize_ws(text)
|
||||||
|
if prompt_norm in text_norm or text_norm in prompt_norm:
|
||||||
|
return i
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"在 {timeout_s}s 内未找到匹配 user 消息(prompt 归一化前 80 字="
|
||||||
|
f"{prompt_norm[:80]!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 等 agent 完成(多信号合流) ────────────────────────────────
|
||||||
|
|
||||||
|
def _chat_sending_false(page: Page) -> bool:
|
||||||
|
val = page.locator('[data-testid="chat-page"]').first.get_attribute("data-sending")
|
||||||
|
return val is not None and val.lower() == "false"
|
||||||
|
|
||||||
|
|
||||||
|
def _window_sending_not_true(page: Page) -> bool:
|
||||||
|
val = page.evaluate("() => window.__jc_sending__")
|
||||||
|
return val is not True
|
||||||
|
|
||||||
|
|
||||||
|
def _no_expanded_execution_graph(page: Page) -> bool:
|
||||||
|
return (
|
||||||
|
page.locator('[data-testid="chat-execution-graph"][data-collapsed="false"]').count()
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_assistant_after(page: Page, user_idx: int) -> Optional[Tuple[int, str, bool]]:
|
||||||
|
items = page.locator('[data-role]')
|
||||||
|
total = items.count()
|
||||||
|
for i in range(total - 1, user_idx, -1):
|
||||||
|
node = items.nth(i)
|
||||||
|
role = (node.get_attribute("data-role") or "").lower()
|
||||||
|
if role != "assistant":
|
||||||
|
continue
|
||||||
|
body = node.inner_text(timeout=2000) or ""
|
||||||
|
streaming = (node.get_attribute("data-streaming") or "false").lower() == "true"
|
||||||
|
return i, body, streaming
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_agent_complete(
|
||||||
|
page: Page,
|
||||||
|
user_idx: int,
|
||||||
|
*,
|
||||||
|
overall_timeout_s: float = 1200.0,
|
||||||
|
stable_seconds: float = 5.0,
|
||||||
|
poll_interval: float = 1.0,
|
||||||
|
) -> str:
|
||||||
|
"""多信号合流等本轮 agent 完成,返回最末 assistant 气泡文本。
|
||||||
|
|
||||||
|
完成条件(全部满足并稳定 stable_seconds 秒):
|
||||||
|
1) chat-page data-sending="false"
|
||||||
|
2) window.__jc_sending__ 不为 True
|
||||||
|
3) 不存在未折叠的 execution graph
|
||||||
|
4) user_idx 之后存在 assistant 节点,有非空 body,data-streaming != "true"
|
||||||
|
5) 上面 assistant 节点的文本长度连续 stable_seconds 秒无变化
|
||||||
|
"""
|
||||||
|
deadline = time.time() + overall_timeout_s
|
||||||
|
last_body_len = -1
|
||||||
|
last_change_at = time.time()
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
chat_ok = _chat_sending_false(page)
|
||||||
|
win_ok = _window_sending_not_true(page)
|
||||||
|
graph_ok = _no_expanded_execution_graph(page)
|
||||||
|
picked = _latest_assistant_after(page, user_idx)
|
||||||
|
|
||||||
|
body_text = ""
|
||||||
|
streaming = True
|
||||||
|
has_body = False
|
||||||
|
if picked is not None:
|
||||||
|
_, body_text, streaming = picked
|
||||||
|
has_body = len(body_text.strip()) > 0
|
||||||
|
|
||||||
|
all_ready = chat_ok and win_ok and graph_ok and has_body and not streaming
|
||||||
|
|
||||||
|
if not all_ready:
|
||||||
|
last_body_len = -1
|
||||||
|
last_change_at = time.time()
|
||||||
|
else:
|
||||||
|
cur_len = len(body_text)
|
||||||
|
if cur_len != last_body_len:
|
||||||
|
last_body_len = cur_len
|
||||||
|
last_change_at = time.time()
|
||||||
|
elif time.time() - last_change_at >= stable_seconds:
|
||||||
|
return body_text
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
raise TimeoutError(
|
||||||
|
f"agent 在 {overall_timeout_s}s 内未完成(user_idx={user_idx} "
|
||||||
|
f"last_body_len={last_body_len})"
|
||||||
|
)
|
||||||
85
src/jiangchang_desktop_sdk/window_win32.py
Normal file
85
src/jiangchang_desktop_sdk/window_win32.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Win32 窗口工具:跨 SDK / screencast 共享的"激活并最大化匠厂主窗口"实现。
|
||||||
|
|
||||||
|
为什么单独抽出来:
|
||||||
|
- SDK 的 bring_to_front 需要 OS 级窗口前置(page.bring_to_front 只切 tab)
|
||||||
|
- screencast 的 run_screencast 录屏前也要把匠厂浮到前面并铺满屏幕
|
||||||
|
- 两者用同一份 EnumWindows + ShowWindow(SW_MAXIMIZE) + SetForegroundWindow 实现,
|
||||||
|
避免代码重复维护
|
||||||
|
|
||||||
|
跨平台行为:
|
||||||
|
- Windows: 真正执行
|
||||||
|
- 其他系统: 直接返回 False(调用方按需 fallback 到协议激活)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
_logger = logging.getLogger("jiangchang_desktop_sdk.window_win32")
|
||||||
|
|
||||||
|
_DEFAULT_KEYWORDS: Tuple[str, ...] = ("匠厂", "Jiangchang", "ClawX", "OpenClaw")
|
||||||
|
SW_MAXIMIZE = 3
|
||||||
|
|
||||||
|
|
||||||
|
def activate_and_maximize_jiangchang_window(
|
||||||
|
keywords: Optional[Tuple[str, ...]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Windows: EnumWindows 找匠厂主窗口 → ShowWindow(SW_MAXIMIZE) + SetForegroundWindow。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keywords: 标题模糊匹配的关键词;不传则用默认
|
||||||
|
("匠厂", "Jiangchang", "ClawX", "OpenClaw")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True: 找到窗口并执行了 ShowWindow + SetForegroundWindow
|
||||||
|
False: 非 Windows / ctypes 不可用 / 找不到匹配窗口 / 任何异常
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.debug("ctypes 不可用:%s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
kw = keywords or _DEFAULT_KEYWORDS
|
||||||
|
matching: list = []
|
||||||
|
|
||||||
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||||||
|
def _enum_proc(hwnd, _lparam):
|
||||||
|
if not user32.IsWindowVisible(hwnd):
|
||||||
|
return True
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
if length == 0:
|
||||||
|
return True
|
||||||
|
buf = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||||
|
if any(k in buf.value for k in kw):
|
||||||
|
matching.append((hwnd, buf.value))
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
user32.EnumWindows(_enum_proc, 0)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.debug("EnumWindows 失败:%s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not matching:
|
||||||
|
_logger.debug("未找到匠厂主窗口(keywords=%s)", kw)
|
||||||
|
return False
|
||||||
|
|
||||||
|
hwnd, title = matching[0]
|
||||||
|
try:
|
||||||
|
user32.ShowWindow(hwnd, SW_MAXIMIZE)
|
||||||
|
user32.SetForegroundWindow(hwnd)
|
||||||
|
_logger.debug("已激活+最大化 hwnd=%s title=%s", hwnd, title)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.debug("ShowWindow/SetForegroundWindow 失败:%s", exc)
|
||||||
|
return False
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
|
from .activity import (
|
||||||
|
emit,
|
||||||
|
finish,
|
||||||
|
get_job_id,
|
||||||
|
get_run_mode,
|
||||||
|
job_context,
|
||||||
|
rpa_step,
|
||||||
|
step,
|
||||||
|
)
|
||||||
|
from .sibling_bridge import (
|
||||||
|
call_sibling_json,
|
||||||
|
call_sibling_json_array,
|
||||||
|
get_sibling_main_path,
|
||||||
|
)
|
||||||
from .client import EntitlementClient
|
from .client import EntitlementClient
|
||||||
|
from .config import ensure_env_file, get, get_bool, get_float, get_int
|
||||||
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
|
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
|
||||||
from .guard import enforce_entitlement
|
from .guard import enforce_entitlement
|
||||||
from .models import EntitlementResult
|
from .models import EntitlementResult
|
||||||
from .runtime_env import (
|
from .runtime_env import (
|
||||||
apply_cli_local_defaults,
|
apply_cli_local_defaults,
|
||||||
|
ensure_runs_dir,
|
||||||
|
find_chrome_executable,
|
||||||
get_data_root,
|
get_data_root,
|
||||||
|
get_runs_dir,
|
||||||
get_sibling_skills_root,
|
get_sibling_skills_root,
|
||||||
get_skills_root,
|
get_skills_root,
|
||||||
get_user_id,
|
get_user_id,
|
||||||
@@ -19,18 +37,86 @@ from .unified_logging import (
|
|||||||
setup_skill_logging,
|
setup_skill_logging,
|
||||||
subprocess_env_with_trace,
|
subprocess_env_with_trace,
|
||||||
)
|
)
|
||||||
|
from .media_assets import (
|
||||||
|
MediaAssetsStatus,
|
||||||
|
background_music_issue,
|
||||||
|
ensure_media_assets,
|
||||||
|
is_git_lfs_pointer,
|
||||||
|
is_usable_audio_file,
|
||||||
|
pick_background_music,
|
||||||
|
probe_background_music,
|
||||||
|
probe_ffmpeg,
|
||||||
|
probe_media_assets,
|
||||||
|
resolve_ffmpeg,
|
||||||
|
resolve_ffprobe,
|
||||||
|
resolve_media_assets_root,
|
||||||
|
resolve_music_dir,
|
||||||
|
)
|
||||||
|
from .runtime_diagnostics import (
|
||||||
|
RuntimeDiagnostics,
|
||||||
|
RuntimeIssue,
|
||||||
|
collect_runtime_diagnostics,
|
||||||
|
format_runtime_health_lines,
|
||||||
|
is_jiangchang_skill_core_from_skill_tree,
|
||||||
|
runtime_diagnostics_dict,
|
||||||
|
version_ge,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import rpa
|
||||||
|
except ImportError:
|
||||||
|
rpa = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"call_sibling_json",
|
||||||
|
"call_sibling_json_array",
|
||||||
|
"emit",
|
||||||
|
"finish",
|
||||||
|
"get_job_id",
|
||||||
|
"get_run_mode",
|
||||||
|
"get_runs_dir",
|
||||||
|
"ensure_runs_dir",
|
||||||
|
"job_context",
|
||||||
|
"rpa_step",
|
||||||
|
"step",
|
||||||
|
"get_sibling_main_path",
|
||||||
"EntitlementClient",
|
"EntitlementClient",
|
||||||
"EntitlementDeniedError",
|
"EntitlementDeniedError",
|
||||||
"EntitlementError",
|
"EntitlementError",
|
||||||
"EntitlementResult",
|
"EntitlementResult",
|
||||||
"EntitlementServiceError",
|
"EntitlementServiceError",
|
||||||
|
"MediaAssetsStatus",
|
||||||
|
"RuntimeDiagnostics",
|
||||||
|
"RuntimeIssue",
|
||||||
|
"background_music_issue",
|
||||||
|
"collect_runtime_diagnostics",
|
||||||
|
"ensure_media_assets",
|
||||||
|
"format_runtime_health_lines",
|
||||||
|
"is_git_lfs_pointer",
|
||||||
|
"is_jiangchang_skill_core_from_skill_tree",
|
||||||
|
"is_usable_audio_file",
|
||||||
|
"probe_background_music",
|
||||||
|
"probe_ffmpeg",
|
||||||
|
"probe_media_assets",
|
||||||
|
"pick_background_music",
|
||||||
|
"resolve_ffmpeg",
|
||||||
|
"resolve_ffprobe",
|
||||||
|
"resolve_media_assets_root",
|
||||||
|
"resolve_music_dir",
|
||||||
|
"runtime_diagnostics_dict",
|
||||||
|
"version_ge",
|
||||||
"apply_cli_local_defaults",
|
"apply_cli_local_defaults",
|
||||||
"attach_unified_file_handler",
|
"attach_unified_file_handler",
|
||||||
|
"ensure_env_file",
|
||||||
"enforce_entitlement",
|
"enforce_entitlement",
|
||||||
"ensure_trace_for_process",
|
"ensure_trace_for_process",
|
||||||
|
"find_chrome_executable",
|
||||||
|
"get",
|
||||||
|
"get_bool",
|
||||||
"get_data_root",
|
"get_data_root",
|
||||||
|
"get_float",
|
||||||
|
"get_int",
|
||||||
|
"get_runs_dir",
|
||||||
"get_sibling_skills_root",
|
"get_sibling_skills_root",
|
||||||
"get_skills_root",
|
"get_skills_root",
|
||||||
"get_skill_log_file_path",
|
"get_skill_log_file_path",
|
||||||
@@ -38,6 +124,7 @@ __all__ = [
|
|||||||
"get_unified_logs_dir",
|
"get_unified_logs_dir",
|
||||||
"get_user_id",
|
"get_user_id",
|
||||||
"platform_default_data_root",
|
"platform_default_data_root",
|
||||||
|
"rpa",
|
||||||
"setup_skill_logging",
|
"setup_skill_logging",
|
||||||
"subprocess_env_with_trace",
|
"subprocess_env_with_trace",
|
||||||
]
|
]
|
||||||
|
|||||||
343
src/jiangchang_skill_core/activity.py
Normal file
343
src/jiangchang_skill_core/activity.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"""
|
||||||
|
Skill Activity Stream (SAS) v1 — Run Journal + stdout result contract.
|
||||||
|
|
||||||
|
emit() / journal events never touch stdout (Agent/bash tool result stays clean).
|
||||||
|
finish() is the only API that writes a single-line result JSON to stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Callable, Iterator, Optional, TypeVar
|
||||||
|
|
||||||
|
from .runtime_env import ensure_runs_dir, get_runs_dir
|
||||||
|
|
||||||
|
_SCHEMA_VERSION = 1
|
||||||
|
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
|
||||||
|
_RESULT_MAX_CHARS = 4096
|
||||||
|
|
||||||
|
_cached_job_id: Optional[str] = None
|
||||||
|
_journal_path: Optional[str] = None
|
||||||
|
_meta_written: bool = False
|
||||||
|
_journal_file: Optional[Any] = None
|
||||||
|
|
||||||
|
F = TypeVar("F", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_debug_enabled() -> bool:
|
||||||
|
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
|
||||||
|
return v in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def get_run_mode() -> str:
|
||||||
|
mode = (os.getenv("JIANGCHANG_RUN_MODE") or "").strip().lower()
|
||||||
|
if mode in ("job", "cli"):
|
||||||
|
return mode
|
||||||
|
return "cli"
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_id() -> str:
|
||||||
|
global _cached_job_id
|
||||||
|
env_job = (os.getenv("JIANGCHANG_JOB_ID") or "").strip()
|
||||||
|
if env_job:
|
||||||
|
return env_job
|
||||||
|
if _cached_job_id is None:
|
||||||
|
_cached_job_id = uuid.uuid4().hex
|
||||||
|
return _cached_job_id
|
||||||
|
|
||||||
|
|
||||||
|
def _get_journal_path() -> str:
|
||||||
|
global _journal_path
|
||||||
|
if _journal_path is None:
|
||||||
|
ensure_runs_dir()
|
||||||
|
_journal_path = os.path.join(get_runs_dir(), f"{get_job_id()}.jsonl")
|
||||||
|
return _journal_path
|
||||||
|
|
||||||
|
|
||||||
|
def _open_journal() -> Any:
|
||||||
|
global _journal_file
|
||||||
|
if _journal_file is None:
|
||||||
|
path = _get_journal_path()
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
_journal_file = open(path, "a", encoding="utf-8")
|
||||||
|
return _journal_file
|
||||||
|
|
||||||
|
|
||||||
|
def _write_journal_line(event: dict[str, Any]) -> None:
|
||||||
|
global _meta_written
|
||||||
|
f = _open_journal()
|
||||||
|
if not _meta_written:
|
||||||
|
meta = {
|
||||||
|
"type": "meta",
|
||||||
|
"schema_version": _SCHEMA_VERSION,
|
||||||
|
"job_id": get_job_id(),
|
||||||
|
"run_mode": get_run_mode(),
|
||||||
|
"ts": _now_ms(),
|
||||||
|
}
|
||||||
|
f.write(json.dumps(meta, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||||
|
f.flush()
|
||||||
|
_meta_written = True
|
||||||
|
f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_event(
|
||||||
|
type_: str,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
skill: str | None = None,
|
||||||
|
stage: str | None = None,
|
||||||
|
current: int | None = None,
|
||||||
|
total: int | None = None,
|
||||||
|
item_id: str | None = None,
|
||||||
|
ts: int | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cleaned = (text or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("text must be non-empty")
|
||||||
|
event: dict[str, Any] = {
|
||||||
|
"type": type_,
|
||||||
|
"text": cleaned,
|
||||||
|
"schema_version": _SCHEMA_VERSION,
|
||||||
|
"ts": ts if ts is not None else _now_ms(),
|
||||||
|
}
|
||||||
|
if skill is not None:
|
||||||
|
event["skill"] = skill
|
||||||
|
if stage is not None:
|
||||||
|
event["stage"] = stage
|
||||||
|
if current is not None:
|
||||||
|
event["current"] = current
|
||||||
|
if total is not None:
|
||||||
|
event["total"] = total
|
||||||
|
if item_id is not None:
|
||||||
|
event["item_id"] = item_id
|
||||||
|
for key, value in extra.items():
|
||||||
|
if key not in event:
|
||||||
|
event[key] = value
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def emit(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
type: str = "activity",
|
||||||
|
skill: str | None = None,
|
||||||
|
stage: str | None = None,
|
||||||
|
current: int | None = None,
|
||||||
|
total: int | None = None,
|
||||||
|
item_id: str | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Append one SAS event to the Run Journal. Never writes to stdout."""
|
||||||
|
if type == "debug" and not _activity_debug_enabled():
|
||||||
|
return
|
||||||
|
if type not in _VALID_EMIT_TYPES:
|
||||||
|
raise ValueError(f"invalid emit type: {type!r}")
|
||||||
|
event = _build_event(
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
skill=skill,
|
||||||
|
stage=stage,
|
||||||
|
current=current,
|
||||||
|
total=total,
|
||||||
|
item_id=item_id,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
_write_journal_line(event)
|
||||||
|
|
||||||
|
|
||||||
|
def step(text: str, **kwargs: Any) -> None:
|
||||||
|
"""Shorthand for emit(type='activity')."""
|
||||||
|
emit(text, type="activity", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_result(result: dict[str, Any]) -> str:
|
||||||
|
line = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if len(line) <= _RESULT_MAX_CHARS:
|
||||||
|
return line
|
||||||
|
|
||||||
|
core_keys = ("type", "schema_version", "status", "skill", "message")
|
||||||
|
truncated: dict[str, Any] = {
|
||||||
|
k: result[k] for k in core_keys if k in result
|
||||||
|
}
|
||||||
|
truncated["truncated"] = True
|
||||||
|
|
||||||
|
for key, value in result.items():
|
||||||
|
if key in truncated or key == "truncated":
|
||||||
|
continue
|
||||||
|
candidate = {**truncated, key: value}
|
||||||
|
candidate_line = json.dumps(candidate, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if len(candidate_line) <= _RESULT_MAX_CHARS:
|
||||||
|
truncated[key] = value
|
||||||
|
|
||||||
|
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if len(line) > _RESULT_MAX_CHARS:
|
||||||
|
truncated.pop("message", None)
|
||||||
|
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if len(line) > _RESULT_MAX_CHARS:
|
||||||
|
return line[:_RESULT_MAX_CHARS]
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def finish(
|
||||||
|
*,
|
||||||
|
status: str = "success",
|
||||||
|
message: str | None = None,
|
||||||
|
skill: str | None = None,
|
||||||
|
**fields: Any,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
1) Write terminal done|error event to Run Journal.
|
||||||
|
2) Print a single compact result JSON line to stdout (Agent/bash path).
|
||||||
|
"""
|
||||||
|
if status not in ("success", "partial", "failed"):
|
||||||
|
raise ValueError(f"invalid finish status: {status!r}")
|
||||||
|
|
||||||
|
journal_type = "error" if status == "failed" else "done"
|
||||||
|
terminal_text = message or ("任务失败" if status == "failed" else "任务完成")
|
||||||
|
|
||||||
|
journal_event = _build_event(
|
||||||
|
journal_type,
|
||||||
|
terminal_text,
|
||||||
|
skill=skill,
|
||||||
|
status=status,
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
|
if message is not None:
|
||||||
|
journal_event["message"] = message
|
||||||
|
_write_journal_line(journal_event)
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"type": "result",
|
||||||
|
"schema_version": _SCHEMA_VERSION,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if skill is not None:
|
||||||
|
result["skill"] = skill
|
||||||
|
if message is not None:
|
||||||
|
result["message"] = message
|
||||||
|
result.update(fields)
|
||||||
|
|
||||||
|
line = _serialize_result(result)
|
||||||
|
sys.stdout.write(line + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def job_context(
|
||||||
|
*,
|
||||||
|
job_id: str | None = None,
|
||||||
|
skill: str | None = None,
|
||||||
|
run_mode: str | None = None,
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Optional wrapper: sets job context; auto finish(failed) on unhandled exception."""
|
||||||
|
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||||
|
|
||||||
|
saved_job_id = _cached_job_id
|
||||||
|
saved_journal_path = _journal_path
|
||||||
|
saved_meta = _meta_written
|
||||||
|
saved_file = _journal_file
|
||||||
|
saved_env_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||||
|
saved_env_mode = os.environ.get("JIANGCHANG_RUN_MODE")
|
||||||
|
|
||||||
|
if job_id:
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = job_id
|
||||||
|
_cached_job_id = None
|
||||||
|
_journal_path = None
|
||||||
|
_meta_written = False
|
||||||
|
if _journal_file is not None:
|
||||||
|
try:
|
||||||
|
_journal_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_journal_file = None
|
||||||
|
if run_mode:
|
||||||
|
os.environ["JIANGCHANG_RUN_MODE"] = run_mode
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception as exc:
|
||||||
|
finish(status="failed", message=str(exc), skill=skill)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_cached_job_id = saved_job_id
|
||||||
|
_journal_path = saved_journal_path
|
||||||
|
_meta_written = saved_meta
|
||||||
|
if _journal_file is not None and _journal_file is not saved_file:
|
||||||
|
try:
|
||||||
|
_journal_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_journal_file = saved_file
|
||||||
|
if saved_env_job is None:
|
||||||
|
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||||
|
else:
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = saved_env_job
|
||||||
|
if saved_env_mode is None:
|
||||||
|
os.environ.pop("JIANGCHANG_RUN_MODE", None)
|
||||||
|
else:
|
||||||
|
os.environ["JIANGCHANG_RUN_MODE"] = saved_env_mode
|
||||||
|
|
||||||
|
|
||||||
|
def rpa_step(name: str | None = None) -> Callable[[F], F]:
|
||||||
|
"""Decorator for sync/async RPA steps: emit enter/success/warn on failure."""
|
||||||
|
|
||||||
|
def decorator(fn: F) -> F:
|
||||||
|
step_name = name or fn.__name__
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(fn):
|
||||||
|
|
||||||
|
@functools.wraps(fn)
|
||||||
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
emit(f"▶ {step_name}")
|
||||||
|
try:
|
||||||
|
result = await fn(*args, **kwargs)
|
||||||
|
emit(f"✓ {step_name}")
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
emit(f"✗ {step_name}: {exc}", type="warn")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return async_wrapper # type: ignore[return-value]
|
||||||
|
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
emit(f"▶ {step_name}")
|
||||||
|
try:
|
||||||
|
result = fn(*args, **kwargs)
|
||||||
|
emit(f"✓ {step_name}")
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
emit(f"✗ {step_name}: {exc}", type="warn")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return sync_wrapper # type: ignore[return-value]
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_for_tests() -> None:
|
||||||
|
"""Clear module-level journal state (tests only)."""
|
||||||
|
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||||
|
if _journal_file is not None:
|
||||||
|
try:
|
||||||
|
_journal_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_cached_job_id = None
|
||||||
|
_journal_path = None
|
||||||
|
_meta_written = False
|
||||||
|
_journal_file = None
|
||||||
188
src/jiangchang_skill_core/config.py
Normal file
188
src/jiangchang_skill_core/config.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""三级优先级配置读取 + 首次 .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 get_env_file_path() -> str | None:
|
||||||
|
return _env_file_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_example_path() -> str | None:
|
||||||
|
return _example_path
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_env_assignments(path: str) -> list[tuple[str, str]]:
|
||||||
|
"""按文件顺序返回 (key, 完整赋值行)。"""
|
||||||
|
result: list[tuple[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.rstrip("\n\r")
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in stripped:
|
||||||
|
continue
|
||||||
|
key, _, _ = stripped.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
if key:
|
||||||
|
result.append((key, stripped))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def merge_missing_env_keys(
|
||||||
|
example_path: str,
|
||||||
|
dest_path: str,
|
||||||
|
*,
|
||||||
|
comment_skill: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""将 example 中有而用户 .env 没有的 key 追加到 dest 末尾,不修改已有项。"""
|
||||||
|
if not example_path or not os.path.isfile(example_path):
|
||||||
|
return []
|
||||||
|
if not dest_path or not os.path.isfile(dest_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
dest_keys = set(_parse_env_file(dest_path).keys())
|
||||||
|
to_append: list[tuple[str, str]] = []
|
||||||
|
for key, assignment in _iter_env_assignments(example_path):
|
||||||
|
if key not in dest_keys:
|
||||||
|
to_append.append((key, assignment))
|
||||||
|
|
||||||
|
if not to_append:
|
||||||
|
return []
|
||||||
|
|
||||||
|
header = (
|
||||||
|
f"# Added by {comment_skill} from .env.example"
|
||||||
|
if comment_skill
|
||||||
|
else "# Added from .env.example"
|
||||||
|
)
|
||||||
|
with open(dest_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n\n" + header + "\n")
|
||||||
|
for _, assignment in to_append:
|
||||||
|
f.write(assignment + "\n")
|
||||||
|
|
||||||
|
reset_cache()
|
||||||
|
return [key for key, _ in to_append]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
805
src/jiangchang_skill_core/media_assets.py
Normal file
805
src/jiangchang_skill_core/media_assets.py
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
"""共享媒体资源解析:背景音乐、字体、水印与 ffmpeg 工具。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
MEDIA_ASSETS_BUNDLE_URL = (
|
||||||
|
"https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip"
|
||||||
|
)
|
||||||
|
MEDIA_ASSETS_ZIP_URL = MEDIA_ASSETS_BUNDLE_URL
|
||||||
|
MEDIA_ASSETS_GIT_URL = "https://git.jc2009.com/client-commons/media-assets"
|
||||||
|
|
||||||
|
_WIN_DEFAULT_DATA_ROOT = Path(r"D:\jiangchang-data")
|
||||||
|
_NON_WIN_DEFAULT_DATA_ROOT = Path.home() / ".jiangchang"
|
||||||
|
|
||||||
|
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
|
||||||
|
_GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
||||||
|
_MIN_AUDIO_BYTES = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def is_git_lfs_pointer(path: str | Path) -> bool:
|
||||||
|
try:
|
||||||
|
head = Path(path).read_bytes()[:256]
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return head.startswith(_GIT_LFS_POINTER_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
_is_git_lfs_pointer = is_git_lfs_pointer
|
||||||
|
|
||||||
|
|
||||||
|
def _enumerate_audio_like(music_dir: Path) -> list[Path]:
|
||||||
|
return [
|
||||||
|
path
|
||||||
|
for path in music_dir.rglob("*")
|
||||||
|
if path.is_file() and path.suffix.lower() in _AUDIO_EXTENSIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _music_content_issue(music_dir: Optional[Path]) -> Optional[str]:
|
||||||
|
if music_dir is None or not music_dir.is_dir():
|
||||||
|
return "music_dir_missing"
|
||||||
|
files = _enumerate_audio_like(music_dir)
|
||||||
|
if not files:
|
||||||
|
return "background_music_dir_empty"
|
||||||
|
if all(_is_git_lfs_pointer(path) for path in files):
|
||||||
|
return "background_music_lfs_pointer_only"
|
||||||
|
if not any(_is_usable_audio_file(path) for path in files):
|
||||||
|
return "background_music_invalid"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _music_is_ready(music_dir: Optional[Path]) -> bool:
|
||||||
|
return _music_content_issue(music_dir) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_music_content_repair(status: MediaAssetsStatus) -> bool:
|
||||||
|
if status.music_dir is None:
|
||||||
|
return "music_dir_missing" in status.warnings
|
||||||
|
issue = _music_content_issue(status.music_dir)
|
||||||
|
return issue in (
|
||||||
|
"background_music_dir_empty",
|
||||||
|
"background_music_lfs_pointer_only",
|
||||||
|
"background_music_invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_usable_audio_file(path: str | Path, *, min_bytes: int = _MIN_AUDIO_BYTES) -> bool:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
return False
|
||||||
|
if p.suffix.lower() not in _AUDIO_EXTENSIONS:
|
||||||
|
return False
|
||||||
|
if is_git_lfs_pointer(p):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if p.stat().st_size < min_bytes:
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_is_usable_audio_file = is_usable_audio_file
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaAssetsStatus:
|
||||||
|
root: Path
|
||||||
|
exists: bool
|
||||||
|
ready: bool
|
||||||
|
source: str
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
ffmpeg_path: Optional[Path] = None
|
||||||
|
ffprobe_path: Optional[Path] = None
|
||||||
|
music_dir: Optional[Path] = None
|
||||||
|
music_ready: bool = False
|
||||||
|
fonts_dir: Optional[Path] = None
|
||||||
|
watermark_dir: Optional[Path] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _env_dict(env: dict[str, str] | None) -> dict[str, str]:
|
||||||
|
return env if env is not None else dict(os.environ)
|
||||||
|
|
||||||
|
|
||||||
|
def _media_assets_bundle_url(env: dict[str, str] | None = None) -> str:
|
||||||
|
env_map = _env_dict(env)
|
||||||
|
override = (env_map.get("MEDIA_ASSETS_BUNDLE_URL") or "").strip()
|
||||||
|
return override or MEDIA_ASSETS_BUNDLE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_jiangchang_data_root(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
env_map = _env_dict(env)
|
||||||
|
from_env = (env_map.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||||
|
if from_env:
|
||||||
|
return Path(from_env)
|
||||||
|
if data_root is not None:
|
||||||
|
return Path(data_root)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return _WIN_DEFAULT_DATA_ROOT
|
||||||
|
return _NON_WIN_DEFAULT_DATA_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_media_assets_root(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
env_map = _env_dict(env)
|
||||||
|
media_root = (env_map.get("MEDIA_ASSETS_ROOT") or "").strip()
|
||||||
|
if media_root:
|
||||||
|
return Path(media_root)
|
||||||
|
data = _resolve_jiangchang_data_root(data_root, env)
|
||||||
|
return data / "shared" / "media-assets"
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_bin_key() -> str:
|
||||||
|
system = platform.system().lower()
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
|
||||||
|
if system == "windows":
|
||||||
|
if machine in {"amd64", "x86_64"}:
|
||||||
|
return "win32-x64"
|
||||||
|
if machine in {"arm64", "aarch64"}:
|
||||||
|
return "win32-arm64"
|
||||||
|
elif system == "darwin":
|
||||||
|
if machine in {"arm64", "aarch64"}:
|
||||||
|
return "darwin-arm64"
|
||||||
|
if machine in {"x86_64", "amd64"}:
|
||||||
|
return "darwin-x64"
|
||||||
|
elif system == "linux":
|
||||||
|
if machine in {"x86_64", "amd64"}:
|
||||||
|
return "linux-x64"
|
||||||
|
if machine in {"arm64", "aarch64"}:
|
||||||
|
return "linux-arm64"
|
||||||
|
|
||||||
|
return f"{system}-{machine}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_filename(name: str) -> str:
|
||||||
|
if platform.system().lower() == "windows":
|
||||||
|
return f"{name}.exe"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_tool_paths(root: Path) -> tuple[Path, Path]:
|
||||||
|
bin_key = _platform_bin_key()
|
||||||
|
bin_dir = root / "bin" / bin_key
|
||||||
|
return (
|
||||||
|
bin_dir / _tool_filename("ffmpeg"),
|
||||||
|
bin_dir / _tool_filename("ffprobe"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest(root: Path) -> dict | None:
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg_platform_spec(manifest: dict) -> dict | None:
|
||||||
|
tools = manifest.get("tools")
|
||||||
|
if not isinstance(tools, dict):
|
||||||
|
return None
|
||||||
|
ffmpeg = tools.get("ffmpeg")
|
||||||
|
if not isinstance(ffmpeg, dict):
|
||||||
|
return None
|
||||||
|
spec = ffmpeg.get(_platform_bin_key())
|
||||||
|
if isinstance(spec, dict):
|
||||||
|
return spec
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_tool_urls(spec: dict) -> list[tuple[str, str]]:
|
||||||
|
urls: list[tuple[str, str]] = []
|
||||||
|
raw_urls = spec.get("urls")
|
||||||
|
if isinstance(raw_urls, list):
|
||||||
|
for item in raw_urls:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
name = str(item.get("name") or "source")
|
||||||
|
url = str(item.get("url") or "").strip()
|
||||||
|
if url:
|
||||||
|
urls.append((name, url))
|
||||||
|
legacy = str(spec.get("url") or "").strip()
|
||||||
|
if legacy:
|
||||||
|
urls.append(("legacy", legacy))
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def _match_archive_member(member_name: str, pattern: str) -> bool:
|
||||||
|
normalized = member_name.replace("\\", "/").lstrip("/")
|
||||||
|
return fnmatch.fnmatch(normalized, pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tool_members(
|
||||||
|
zip_path: Path,
|
||||||
|
archive_paths: dict[str, str],
|
||||||
|
) -> dict[str, bytes]:
|
||||||
|
found: dict[str, bytes] = {}
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
for tool_name, pattern in archive_paths.items():
|
||||||
|
if not isinstance(pattern, str) or not pattern:
|
||||||
|
continue
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
if _match_archive_member(info.filename, pattern):
|
||||||
|
found[tool_name] = zf.read(info)
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _try_download_ffmpeg_tools(root: Path, warnings: list[str]) -> bool:
|
||||||
|
manifest = _read_manifest(root)
|
||||||
|
if manifest is None:
|
||||||
|
warnings.append("ffmpeg_manifest_missing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
spec = _ffmpeg_platform_spec(manifest)
|
||||||
|
if spec is None:
|
||||||
|
warnings.append("ffmpeg_manifest_missing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
archive_paths = spec.get("archive_paths")
|
||||||
|
if not isinstance(archive_paths, dict):
|
||||||
|
warnings.append("ffmpeg_manifest_missing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
urls = _manifest_tool_urls(spec)
|
||||||
|
if not urls:
|
||||||
|
warnings.append("ffmpeg_tool_download_failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
ffmpeg_path, ffprobe_path = _platform_tool_paths(root)
|
||||||
|
bin_dir = ffmpeg_path.parent
|
||||||
|
download_root = _download_dir(_shared_parent(root)) / "ffmpeg-tools"
|
||||||
|
download_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for source_name, url in urls:
|
||||||
|
zip_path = download_root / f"{source_name}.zip"
|
||||||
|
try:
|
||||||
|
if zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
_download_zip(url, zip_path)
|
||||||
|
members = _extract_tool_members(zip_path, archive_paths)
|
||||||
|
ffmpeg_bytes = members.get("ffmpeg")
|
||||||
|
ffprobe_bytes = members.get("ffprobe")
|
||||||
|
if not ffmpeg_bytes or not ffprobe_bytes:
|
||||||
|
continue
|
||||||
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ffmpeg_path.write_bytes(ffmpeg_bytes)
|
||||||
|
ffprobe_path.write_bytes(ffprobe_bytes)
|
||||||
|
shutil.rmtree(download_root, ignore_errors=True)
|
||||||
|
return True
|
||||||
|
except urllib.error.URLError:
|
||||||
|
continue
|
||||||
|
except (zipfile.BadZipFile, OSError, KeyError):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
warnings.append("ffmpeg_tool_download_failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_ensure_ffmpeg_tools(root: Path, status: MediaAssetsStatus) -> MediaAssetsStatus:
|
||||||
|
if status.ready:
|
||||||
|
return status
|
||||||
|
if not status.exists:
|
||||||
|
return status
|
||||||
|
if "ffmpeg_missing" not in status.warnings and "ffprobe_missing" not in status.warnings:
|
||||||
|
return status
|
||||||
|
|
||||||
|
warnings = list(status.warnings)
|
||||||
|
if _try_download_ffmpeg_tools(root, warnings):
|
||||||
|
refreshed = _inspect_media_assets(root, source=status.source)
|
||||||
|
for warning in warnings:
|
||||||
|
if warning not in refreshed.warnings:
|
||||||
|
refreshed.warnings.append(warning)
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
refreshed = _inspect_media_assets(root, source=status.source)
|
||||||
|
for warning in warnings:
|
||||||
|
if warning not in refreshed.warnings:
|
||||||
|
refreshed.warnings.append(warning)
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
|
||||||
|
warnings: list[str] = []
|
||||||
|
exists = root.exists()
|
||||||
|
music_dir = root / "music"
|
||||||
|
fonts_dir = root / "fonts"
|
||||||
|
watermark_dir = root / "watermark"
|
||||||
|
ffmpeg_path, ffprobe_path = _platform_tool_paths(root)
|
||||||
|
|
||||||
|
music_exists = music_dir.is_dir()
|
||||||
|
fonts_exists = fonts_dir.is_dir()
|
||||||
|
watermark_exists = watermark_dir.is_dir()
|
||||||
|
ffmpeg_exists = ffmpeg_path.is_file()
|
||||||
|
ffprobe_exists = ffprobe_path.is_file()
|
||||||
|
|
||||||
|
if not music_exists:
|
||||||
|
warnings.append("music_dir_missing")
|
||||||
|
if not fonts_exists:
|
||||||
|
warnings.append("fonts_dir_missing")
|
||||||
|
if not watermark_exists:
|
||||||
|
warnings.append("watermark_dir_missing")
|
||||||
|
if not ffmpeg_exists:
|
||||||
|
warnings.append("ffmpeg_missing")
|
||||||
|
if not ffprobe_exists:
|
||||||
|
warnings.append("ffprobe_missing")
|
||||||
|
|
||||||
|
music_ready = False
|
||||||
|
if music_exists:
|
||||||
|
music_issue = _music_content_issue(music_dir)
|
||||||
|
if music_issue and music_issue != "music_dir_missing":
|
||||||
|
warnings.append(music_issue)
|
||||||
|
music_ready = music_issue is None
|
||||||
|
|
||||||
|
ready = (
|
||||||
|
exists
|
||||||
|
and music_exists
|
||||||
|
and fonts_exists
|
||||||
|
and watermark_exists
|
||||||
|
and ffmpeg_exists
|
||||||
|
and ffprobe_exists
|
||||||
|
and music_ready
|
||||||
|
)
|
||||||
|
|
||||||
|
return MediaAssetsStatus(
|
||||||
|
root=root,
|
||||||
|
exists=exists,
|
||||||
|
ready=ready,
|
||||||
|
source=source,
|
||||||
|
warnings=warnings,
|
||||||
|
ffmpeg_path=ffmpeg_path if ffmpeg_exists else None,
|
||||||
|
ffprobe_path=ffprobe_path if ffprobe_exists else None,
|
||||||
|
music_dir=music_dir if music_exists else None,
|
||||||
|
music_ready=music_ready,
|
||||||
|
fonts_dir=fonts_dir if fonts_exists else None,
|
||||||
|
watermark_dir=watermark_dir if watermark_exists else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shared_parent(root: Path) -> Path:
|
||||||
|
return root.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _download_dir(shared: Path) -> Path:
|
||||||
|
return shared / ".media-assets-download"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_media_assets_root_in_tree(extracted: Path) -> Path | None:
|
||||||
|
if not extracted.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _looks_like_assets(path: Path) -> bool:
|
||||||
|
if (path / "music").is_dir():
|
||||||
|
return True
|
||||||
|
if (path / "fonts").is_dir():
|
||||||
|
return True
|
||||||
|
if (path / "watermark").is_dir():
|
||||||
|
return True
|
||||||
|
if (path / "README.md").is_file():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if _looks_like_assets(extracted):
|
||||||
|
return extracted
|
||||||
|
|
||||||
|
children = [p for p in extracted.iterdir() if p.is_dir()]
|
||||||
|
for child in children:
|
||||||
|
if _looks_like_assets(child):
|
||||||
|
return child
|
||||||
|
|
||||||
|
for path in extracted.rglob("*"):
|
||||||
|
if path.is_dir() and _looks_like_assets(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _download_zip(url: str, dest: Path) -> None:
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with urllib.request.urlopen(url, timeout=120) as response:
|
||||||
|
dest.write_bytes(response.read())
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_zip(zip_path: Path, extract_to: Path) -> None:
|
||||||
|
extract_to.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
zf.extractall(extract_to)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_git_executable() -> Path | None:
|
||||||
|
git = shutil.which("git")
|
||||||
|
return Path(git) if git else None
|
||||||
|
|
||||||
|
|
||||||
|
def _git_clone(url: str, target: Path) -> None:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "clone", url, str(target)],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_pull(target: Path) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "pull"],
|
||||||
|
cwd=str(target),
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_git_repo(path: Path) -> bool:
|
||||||
|
return (path / ".git").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _install_media_assets(source: Path, target: Path) -> None:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not target.exists():
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
return
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
backup = target.parent / f"media-assets.backup-{timestamp}"
|
||||||
|
shutil.move(str(target), str(backup))
|
||||||
|
try:
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
except Exception:
|
||||||
|
if backup.exists() and not target.exists():
|
||||||
|
shutil.move(str(backup), str(target))
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
shutil.rmtree(backup, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_download_artifacts(download_root: Path) -> None:
|
||||||
|
shutil.rmtree(download_root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_download_via_zip(
|
||||||
|
shared: Path,
|
||||||
|
target: Path,
|
||||||
|
warnings: list[str],
|
||||||
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
download_root = _download_dir(shared)
|
||||||
|
download_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
zip_path = download_root / "media-assets.zip"
|
||||||
|
extracted = download_root / "extracted"
|
||||||
|
staging = download_root / "staging"
|
||||||
|
bundle_url = url or _media_assets_bundle_url(env)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
if extracted.exists():
|
||||||
|
shutil.rmtree(extracted, ignore_errors=True)
|
||||||
|
|
||||||
|
_download_zip(bundle_url, zip_path)
|
||||||
|
_extract_zip(zip_path, extracted)
|
||||||
|
|
||||||
|
found = _find_media_assets_root_in_tree(extracted)
|
||||||
|
if found is None:
|
||||||
|
warnings.append("media_assets_zip_invalid_layout")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
shutil.copytree(found, staging)
|
||||||
|
_install_media_assets(staging, target)
|
||||||
|
_cleanup_download_artifacts(download_root)
|
||||||
|
return True
|
||||||
|
except urllib.error.URLError:
|
||||||
|
warnings.append("media_assets_zip_download_failed")
|
||||||
|
return False
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
warnings.append("media_assets_zip_extract_failed")
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
warnings.append("media_assets_zip_extract_failed")
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
warnings.append("media_assets_zip_extract_failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _try_download_via_git(
|
||||||
|
target: Path,
|
||||||
|
warnings: list[str],
|
||||||
|
*,
|
||||||
|
clone: bool,
|
||||||
|
) -> bool:
|
||||||
|
git_exe = _find_git_executable()
|
||||||
|
if git_exe is None:
|
||||||
|
warnings.append("git_not_available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if clone:
|
||||||
|
if target.exists():
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
partial = target.parent / f"media-assets.git-staging-{timestamp}"
|
||||||
|
if partial.exists():
|
||||||
|
shutil.rmtree(partial, ignore_errors=True)
|
||||||
|
_git_clone(MEDIA_ASSETS_GIT_URL, partial)
|
||||||
|
_install_media_assets(partial, target)
|
||||||
|
else:
|
||||||
|
_git_clone(MEDIA_ASSETS_GIT_URL, target)
|
||||||
|
else:
|
||||||
|
_git_pull(target)
|
||||||
|
return True
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
if clone:
|
||||||
|
warnings.append("media_assets_git_clone_failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _media_assets_root_overridden(env: dict[str, str] | None) -> bool:
|
||||||
|
env_map = _env_dict(env)
|
||||||
|
return bool((env_map.get("MEDIA_ASSETS_ROOT") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_media_repo_content(status: MediaAssetsStatus) -> bool:
|
||||||
|
return any(
|
||||||
|
warning in status.warnings
|
||||||
|
for warning in (
|
||||||
|
"music_dir_missing",
|
||||||
|
"fonts_dir_missing",
|
||||||
|
"watermark_dir_missing",
|
||||||
|
"background_music_dir_empty",
|
||||||
|
"background_music_lfs_pointer_only",
|
||||||
|
"background_music_invalid",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_repair_music_content(
|
||||||
|
root: Path,
|
||||||
|
status: MediaAssetsStatus,
|
||||||
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> MediaAssetsStatus:
|
||||||
|
if not _needs_music_content_repair(status):
|
||||||
|
return status
|
||||||
|
if _media_assets_root_overridden(env):
|
||||||
|
return status
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
if _is_git_repo(root) and root.exists():
|
||||||
|
if _try_download_via_git(root, warnings, clone=False):
|
||||||
|
refreshed = _inspect_media_assets(root, source="git")
|
||||||
|
for warning in warnings:
|
||||||
|
if warning not in refreshed.warnings:
|
||||||
|
refreshed.warnings.append(warning)
|
||||||
|
return _maybe_ensure_ffmpeg_tools(root, refreshed)
|
||||||
|
|
||||||
|
shared = _shared_parent(root)
|
||||||
|
if _try_download_via_zip(shared, root, warnings, env=env):
|
||||||
|
refreshed = _inspect_media_assets(root, source="zip")
|
||||||
|
for warning in warnings:
|
||||||
|
if warning not in refreshed.warnings:
|
||||||
|
refreshed.warnings.append(warning)
|
||||||
|
return _maybe_ensure_ffmpeg_tools(root, refreshed)
|
||||||
|
|
||||||
|
refreshed = _inspect_media_assets(root, source=status.source)
|
||||||
|
for warning in warnings:
|
||||||
|
if warning not in refreshed.warnings:
|
||||||
|
refreshed.warnings.append(warning)
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_media_assets(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
update: bool = False,
|
||||||
|
) -> MediaAssetsStatus:
|
||||||
|
env_map = _env_dict(env)
|
||||||
|
root = resolve_media_assets_root(data_root, env_map)
|
||||||
|
overridden = _media_assets_root_overridden(env_map)
|
||||||
|
|
||||||
|
status = _inspect_media_assets(root, source="local")
|
||||||
|
if overridden:
|
||||||
|
return _maybe_ensure_ffmpeg_tools(root, status)
|
||||||
|
|
||||||
|
if status.exists and not status.ready and not update and not _needs_media_repo_content(status):
|
||||||
|
return _maybe_ensure_ffmpeg_tools(root, status)
|
||||||
|
|
||||||
|
if status.ready and not update:
|
||||||
|
return status
|
||||||
|
|
||||||
|
if status.ready and update:
|
||||||
|
if _is_git_repo(root):
|
||||||
|
warnings: list[str] = []
|
||||||
|
if _try_download_via_git(root, warnings, clone=False):
|
||||||
|
return _maybe_ensure_ffmpeg_tools(
|
||||||
|
root,
|
||||||
|
_inspect_media_assets(root, source="git"),
|
||||||
|
)
|
||||||
|
status = _inspect_media_assets(root, source="local")
|
||||||
|
status.warnings.extend(warnings)
|
||||||
|
return _maybe_ensure_ffmpeg_tools(root, status)
|
||||||
|
|
||||||
|
shared = _shared_parent(root)
|
||||||
|
shared.mkdir(parents=True, exist_ok=True)
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if _try_download_via_zip(shared, root, warnings, env=env_map):
|
||||||
|
return _maybe_ensure_ffmpeg_tools(
|
||||||
|
root,
|
||||||
|
_maybe_repair_music_content(
|
||||||
|
root,
|
||||||
|
_inspect_media_assets(root, source="zip"),
|
||||||
|
env=env_map,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
clone_needed = not root.exists() or not status.ready or update
|
||||||
|
if _try_download_via_git(root, warnings, clone=clone_needed):
|
||||||
|
return _maybe_ensure_ffmpeg_tools(
|
||||||
|
root,
|
||||||
|
_maybe_repair_music_content(
|
||||||
|
root,
|
||||||
|
_inspect_media_assets(
|
||||||
|
root,
|
||||||
|
source="git" if _is_git_repo(root) else "local",
|
||||||
|
),
|
||||||
|
env=env_map,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
final = _inspect_media_assets(root, source="local")
|
||||||
|
for warning in warnings:
|
||||||
|
if warning not in final.warnings:
|
||||||
|
final.warnings.append(warning)
|
||||||
|
repaired = _maybe_repair_music_content(root, final, env=env_map)
|
||||||
|
for warning in warnings:
|
||||||
|
if warning not in repaired.warnings:
|
||||||
|
repaired.warnings.append(warning)
|
||||||
|
return _maybe_ensure_ffmpeg_tools(root, repaired)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_media_assets(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> MediaAssetsStatus:
|
||||||
|
"""只检查本地 media-assets 目录,不触发下载。"""
|
||||||
|
env_map = _env_dict(env)
|
||||||
|
root = resolve_media_assets_root(data_root, env_map)
|
||||||
|
return _inspect_media_assets(root, source="local")
|
||||||
|
|
||||||
|
|
||||||
|
def probe_ffmpeg(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
status = probe_media_assets(data_root, env)
|
||||||
|
return status.ffmpeg_path
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ffmpeg(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
status = ensure_media_assets(data_root, env)
|
||||||
|
return status.ffmpeg_path
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ffprobe(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
status = ensure_media_assets(data_root, env)
|
||||||
|
return status.ffprobe_path
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_music_dir(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
status = ensure_media_assets(data_root, env)
|
||||||
|
return status.music_dir
|
||||||
|
|
||||||
|
|
||||||
|
def probe_background_music(
|
||||||
|
root: str | Path | None = None,
|
||||||
|
*,
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""只读探测背景音乐目录,不触发 media-assets 下载。"""
|
||||||
|
env_map = _env_dict(dict(env) if env is not None else None)
|
||||||
|
if root is not None:
|
||||||
|
music_dir = Path(root) / "music"
|
||||||
|
if not music_dir.is_dir():
|
||||||
|
music_dir = Path(root) if Path(root).is_dir() else None
|
||||||
|
else:
|
||||||
|
data_root = (env_map.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||||
|
assets_root = resolve_media_assets_root(data_root or None, env_map)
|
||||||
|
status = _inspect_media_assets(assets_root, source="local")
|
||||||
|
music_dir = status.music_dir
|
||||||
|
|
||||||
|
music_root = str(music_dir) if music_dir is not None else None
|
||||||
|
if music_dir is None or not music_dir.is_dir():
|
||||||
|
return {
|
||||||
|
"music_root": music_root,
|
||||||
|
"mp3_count": 0,
|
||||||
|
"usable_count": 0,
|
||||||
|
"sample_path": None,
|
||||||
|
"issue": "music_dir_missing",
|
||||||
|
}
|
||||||
|
|
||||||
|
mp3_files = [path for path in music_dir.rglob("*.mp3") if path.is_file()]
|
||||||
|
usable = [path for path in music_dir.rglob("*") if is_usable_audio_file(path)]
|
||||||
|
issue = _music_content_issue(music_dir)
|
||||||
|
sample = str(sorted(usable, key=lambda p: str(p).lower())[0]) if usable else None
|
||||||
|
return {
|
||||||
|
"music_root": music_root,
|
||||||
|
"mp3_count": len(mp3_files),
|
||||||
|
"usable_count": len(usable),
|
||||||
|
"sample_path": sample,
|
||||||
|
"issue": issue,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def background_music_issue(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""若 music 目录存在但无可用音频,返回标准 warning code。"""
|
||||||
|
if pick_background_music(data_root, env) is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status = ensure_media_assets(data_root, env)
|
||||||
|
return _music_content_issue(status.music_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def pick_background_music(
|
||||||
|
data_root: str | Path | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
status = ensure_media_assets(data_root, env)
|
||||||
|
music_dir = status.music_dir
|
||||||
|
if music_dir is None or not music_dir.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
path
|
||||||
|
for path in music_dir.rglob("*")
|
||||||
|
if _is_usable_audio_file(path)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates.sort(key=lambda p: str(p).lower())
|
||||||
|
return candidates[0]
|
||||||
65
src/jiangchang_skill_core/rpa/__init__.py
Normal file
65
src/jiangchang_skill_core/rpa/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""浏览器 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,
|
||||||
|
)
|
||||||
215
src/jiangchang_skill_core/rpa/anti_detect.py
Normal file
215
src/jiangchang_skill_core/rpa/anti_detect.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""防反爬:随机延迟、人工鼠标轨迹模拟、拟人滚动。
|
||||||
|
|
||||||
|
所有延迟使用 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
src/jiangchang_skill_core/rpa/artifacts.py
Normal file
29
src/jiangchang_skill_core/rpa/artifacts.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""RPA 失败存证路径与截图。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
|
||||||
|
|
||||||
|
def _artifacts_enabled() -> bool:
|
||||||
|
return config.get_bool("OPENCLAW_ARTIFACTS_ON_FAILURE", default=True)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
57
src/jiangchang_skill_core/rpa/browser.py
Normal file
57
src/jiangchang_skill_core/rpa/browser.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""统一 persistent context 启动封装(仅浏览器自动化,不负责录屏)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
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:
|
||||||
|
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||||
|
|
||||||
|
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
|
||||||
19
src/jiangchang_skill_core/rpa/errors.py
Normal file
19
src/jiangchang_skill_core/rpa/errors.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
96
src/jiangchang_skill_core/rpa/human_login.py
Normal file
96
src/jiangchang_skill_core/rpa/human_login.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""验证码/登录 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
|
||||||
65
src/jiangchang_skill_core/rpa/stealth.py
Normal file
65
src/jiangchang_skill_core/rpa/stealth.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""淡化 Playwright 自动化指纹,降低 baxia/x5sec 风控命中率。
|
||||||
|
|
||||||
|
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0(或 false/off/no)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
|
||||||
|
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:
|
||||||
|
return config.get_bool("OPENCLAW_PLAYWRIGHT_STEALTH", default=True)
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
939
src/jiangchang_skill_core/rpa/video_session.py
Normal file
939
src/jiangchang_skill_core/rpa/video_session.py
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
"""RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 旁白 + 背景音乐 → 最终 MP4。
|
||||||
|
|
||||||
|
背景音乐默认循环铺满录屏时长并在结尾淡出;旁白使用 Windows 本地 SAPI 合成,
|
||||||
|
失败时静默降级,不影响视频生成。Skill 无需额外配置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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
|
||||||
|
from ..media_assets import (
|
||||||
|
background_music_issue,
|
||||||
|
ensure_media_assets,
|
||||||
|
pick_background_music,
|
||||||
|
resolve_ffmpeg,
|
||||||
|
resolve_ffprobe,
|
||||||
|
)
|
||||||
|
|
||||||
|
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||||
|
"FontName=Arial,FontSize=14,"
|
||||||
|
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||||||
|
"Outline=2,Shadow=1,Alignment=2"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CAPTURE_FRAMERATE = 15
|
||||||
|
|
||||||
|
# 背景音乐循环 + 结尾淡出(默认策略,非 env 配置)
|
||||||
|
_MUSIC_VOLUME = 0.12
|
||||||
|
_VOICE_VOLUME = 1.0
|
||||||
|
_MUSIC_FADE_OUT_SECONDS = 3.0
|
||||||
|
_MIN_MUSIC_FADE_OUT_SECONDS = 1.0
|
||||||
|
_SHORT_VIDEO_FADE_RATIO = 0.25
|
||||||
|
|
||||||
|
_VOICEOVER_MAX_CHARS = 60
|
||||||
|
_VOICEOVER_DEDUP_SECONDS = 10.0
|
||||||
|
_VOICEOVER_MIN_GAP_SECONDS = 2.0
|
||||||
|
|
||||||
|
_INTRO_BUFFER_SECONDS = 5.0
|
||||||
|
_INTRO_STABILIZE_SECONDS = 1.0
|
||||||
|
_OUTRO_BUFFER_SECONDS = 5.0
|
||||||
|
|
||||||
|
_VOICEOVER_SKIP_SUBSTRINGS = (
|
||||||
|
"等待 1-5s",
|
||||||
|
"跳过重复",
|
||||||
|
"写入联系人库",
|
||||||
|
"探针模式",
|
||||||
|
"异常",
|
||||||
|
"失败",
|
||||||
|
)
|
||||||
|
|
||||||
|
_VOICEOVER_IMPORTANT_SUBSTRINGS = (
|
||||||
|
"开始",
|
||||||
|
"打开",
|
||||||
|
"启动",
|
||||||
|
"定位",
|
||||||
|
"输入",
|
||||||
|
"点击",
|
||||||
|
"搜索",
|
||||||
|
"等待搜索结果",
|
||||||
|
"检查登录状态",
|
||||||
|
"处理验证码",
|
||||||
|
"等待人工验证",
|
||||||
|
"解析",
|
||||||
|
"提取",
|
||||||
|
"采集完成",
|
||||||
|
"部分完成",
|
||||||
|
"完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 明显技术日志:时间戳前缀、纯数字进度等
|
||||||
|
_TECH_LOG_RE = re.compile(
|
||||||
|
r"^\d{4}-\d{2}-\d{2}|^\[\w+\]|^DEBUG:|^INFO:|^WARN:|^ERROR:"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_video_enabled() -> bool:
|
||||||
|
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ffmpeg_exe() -> Optional[Path]:
|
||||||
|
ffmpeg = resolve_ffmpeg()
|
||||||
|
if ffmpeg is None:
|
||||||
|
return None
|
||||||
|
path = Path(ffmpeg)
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ffprobe_exe() -> Optional[Path]:
|
||||||
|
"""定位 ffprobe;优先 media_assets bundle,否则尝试 ffmpeg 同目录。"""
|
||||||
|
ffprobe = resolve_ffprobe()
|
||||||
|
if ffprobe is not None:
|
||||||
|
path = Path(ffprobe)
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
ffmpeg = _resolve_ffmpeg_exe()
|
||||||
|
if ffmpeg is not None:
|
||||||
|
sibling = ffmpeg.parent / ("ffprobe.exe" if sys.platform == "win32" else "ffprobe")
|
||||||
|
if sibling.is_file():
|
||||||
|
return sibling
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_media_duration_seconds(
|
||||||
|
ffprobe_exe: Optional[Path], media_path: Path
|
||||||
|
) -> Optional[float]:
|
||||||
|
if ffprobe_exe is None or not media_path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
str(ffprobe_exe),
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=nk=1:nw=1",
|
||||||
|
str(media_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
raw = (result.stdout or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
value = float(raw)
|
||||||
|
return value if value > 0 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _music_fade_params(
|
||||||
|
duration_seconds: Optional[float],
|
||||||
|
) -> tuple[Optional[float], Optional[float]]:
|
||||||
|
if duration_seconds is None or duration_seconds <= 0:
|
||||||
|
return (None, None)
|
||||||
|
fade = min(
|
||||||
|
_MUSIC_FADE_OUT_SECONDS,
|
||||||
|
max(_MIN_MUSIC_FADE_OUT_SECONDS, duration_seconds * _SHORT_VIDEO_FADE_RATIO),
|
||||||
|
)
|
||||||
|
fade_start = max(0.0, duration_seconds - fade)
|
||||||
|
return (fade_start, fade)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_music_file(warnings: List[str]) -> Optional[Path]:
|
||||||
|
music = pick_background_music()
|
||||||
|
if music is not None:
|
||||||
|
return music
|
||||||
|
|
||||||
|
issue = background_music_issue()
|
||||||
|
if issue:
|
||||||
|
warnings.append(issue)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StepEntry:
|
||||||
|
start: float
|
||||||
|
text: str
|
||||||
|
duration: float = 4.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _VoiceClip:
|
||||||
|
path: Path
|
||||||
|
delay_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
def _voiceover_text_for_step(text: str) -> Optional[str]:
|
||||||
|
"""过滤适合朗读的步骤文本;噪音/技术日志返回 None。"""
|
||||||
|
cleaned = (text or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
if _TECH_LOG_RE.search(cleaned):
|
||||||
|
return None
|
||||||
|
for skip in _VOICEOVER_SKIP_SUBSTRINGS:
|
||||||
|
if skip in cleaned:
|
||||||
|
return None
|
||||||
|
if len(cleaned) > _VOICEOVER_MAX_CHARS:
|
||||||
|
cleaned = cleaned[:_VOICEOVER_MAX_CHARS]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _is_important_voiceover(text: str) -> bool:
|
||||||
|
for important in _VOICEOVER_IMPORTANT_SUBSTRINGS:
|
||||||
|
if important in text:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _select_voiceover_clips(
|
||||||
|
entries: List[_StepEntry],
|
||||||
|
) -> List[tuple[_StepEntry, str]]:
|
||||||
|
"""按时间排序,去重并保证旁白间隔;关键动作可绕过最小间隔。"""
|
||||||
|
result: List[tuple[_StepEntry, str]] = []
|
||||||
|
last_text_at: Dict[str, float] = {}
|
||||||
|
last_spoken_at = -_VOICEOVER_MIN_GAP_SECONDS
|
||||||
|
|
||||||
|
for entry in sorted(entries, key=lambda e: e.start):
|
||||||
|
spoken = _voiceover_text_for_step(entry.text)
|
||||||
|
if not spoken:
|
||||||
|
continue
|
||||||
|
prev = last_text_at.get(spoken)
|
||||||
|
if prev is not None and entry.start - prev < _VOICEOVER_DEDUP_SECONDS:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
not _is_important_voiceover(spoken)
|
||||||
|
and entry.start - last_spoken_at < _VOICEOVER_MIN_GAP_SECONDS
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
last_text_at[spoken] = entry.start
|
||||||
|
last_spoken_at = entry.start
|
||||||
|
result.append((entry, spoken))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_available() -> bool:
|
||||||
|
return sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
def _sapi_synthesize_wav(text: str, output_wav: Path) -> bool:
|
||||||
|
if not _tts_available():
|
||||||
|
return False
|
||||||
|
output_wav.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_str = str(output_wav.resolve()).replace("'", "''")
|
||||||
|
text_esc = text.replace("'", "''")
|
||||||
|
ps_script = (
|
||||||
|
"Add-Type -AssemblyName System.Speech; "
|
||||||
|
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
|
||||||
|
f"$s.SetOutputToWaveFile('{out_str}'); "
|
||||||
|
f"$s.Speak('{text_esc}'); "
|
||||||
|
"$s.Dispose()"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
result.returncode == 0
|
||||||
|
and output_wav.is_file()
|
||||||
|
and output_wav.stat().st_size > 0
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _mix_voice_clips(
|
||||||
|
ffmpeg_exe: Path, clips: List[_VoiceClip], output_wav: Path
|
||||||
|
) -> bool:
|
||||||
|
if not clips:
|
||||||
|
return False
|
||||||
|
output_wav.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
filter_parts: List[str] = []
|
||||||
|
mix_labels: List[str] = []
|
||||||
|
cmd_inputs: List[str] = []
|
||||||
|
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
cmd_inputs.extend(["-i", str(clip.path)])
|
||||||
|
label = f"v{i}"
|
||||||
|
filter_parts.append(
|
||||||
|
f"[{i}:a]adelay={clip.delay_ms}|{clip.delay_ms},"
|
||||||
|
f"volume={_VOICE_VOLUME}[{label}]"
|
||||||
|
)
|
||||||
|
mix_labels.append(f"[{label}]")
|
||||||
|
|
||||||
|
if len(clips) == 1:
|
||||||
|
filter_complex = filter_parts[0].replace(f"[v0]", "[out]")
|
||||||
|
# single clip: relabel output
|
||||||
|
filter_complex = (
|
||||||
|
f"[0:a]adelay={clips[0].delay_ms}|{clips[0].delay_ms},"
|
||||||
|
f"volume={_VOICE_VOLUME}[out]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
filter_complex = (
|
||||||
|
";".join(filter_parts)
|
||||||
|
+ ";"
|
||||||
|
+ "".join(mix_labels)
|
||||||
|
+ f"amix=inputs={len(clips)}:duration=longest:dropout_transition=0:normalize=0[out]"
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_tail = [
|
||||||
|
"-y",
|
||||||
|
*cmd_inputs,
|
||||||
|
"-filter_complex",
|
||||||
|
filter_complex,
|
||||||
|
"-map",
|
||||||
|
"[out]",
|
||||||
|
str(output_wav),
|
||||||
|
]
|
||||||
|
ok, _ = _run_ffmpeg(ffmpeg_exe, cmd_tail)
|
||||||
|
return ok and output_wav.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def _synthesize_step_voiceover(
|
||||||
|
entries: List[_StepEntry],
|
||||||
|
output_wav: Path,
|
||||||
|
warnings: List[str],
|
||||||
|
*,
|
||||||
|
ffmpeg_exe: Optional[Path] = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""本地 Windows SAPI 逐条合成旁白并按步骤 start 时间对齐;失败返回 None。"""
|
||||||
|
if not _tts_available():
|
||||||
|
warnings.append("tts_unsupported_platform")
|
||||||
|
return None
|
||||||
|
|
||||||
|
clips_to_speak = _select_voiceover_clips(entries)
|
||||||
|
if not clips_to_speak:
|
||||||
|
return None
|
||||||
|
|
||||||
|
voice_dir = output_wav.parent
|
||||||
|
voice_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_clips: List[_VoiceClip] = []
|
||||||
|
|
||||||
|
for i, (entry, spoken) in enumerate(clips_to_speak):
|
||||||
|
clip_path = voice_dir / f"voice_{i:03d}.wav"
|
||||||
|
if not _sapi_synthesize_wav(spoken, clip_path):
|
||||||
|
warnings.append("tts_synthesis_failed")
|
||||||
|
return None
|
||||||
|
delay_ms = max(0, int(entry.start * 1000))
|
||||||
|
temp_clips.append(_VoiceClip(path=clip_path, delay_ms=delay_ms))
|
||||||
|
|
||||||
|
if ffmpeg_exe is None:
|
||||||
|
ffmpeg_exe = _resolve_ffmpeg_exe()
|
||||||
|
if ffmpeg_exe is None:
|
||||||
|
warnings.append("tts_mix_ffmpeg_missing")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not _mix_voice_clips(ffmpeg_exe, temp_clips, output_wav):
|
||||||
|
warnings.append("tts_mix_failed")
|
||||||
|
return None
|
||||||
|
return output_wav
|
||||||
|
|
||||||
|
|
||||||
|
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 _ffmpeg_cmd(ffmpeg_exe: Path, *args: str) -> List[str]:
|
||||||
|
return [str(ffmpeg_exe), *args]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: List[str]) -> tuple[bool, str]:
|
||||||
|
cmd = _ffmpeg_cmd(ffmpeg_exe, *cmd_tail)
|
||||||
|
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_music_filter(
|
||||||
|
audio_input_idx: int,
|
||||||
|
fade_start: Optional[float],
|
||||||
|
fade_duration: Optional[float],
|
||||||
|
output_label: str = "music",
|
||||||
|
) -> str:
|
||||||
|
filt = f"[{audio_input_idx}:a]volume={_MUSIC_VOLUME}"
|
||||||
|
if fade_start is not None and fade_duration is not None:
|
||||||
|
filt += f",afade=t=out:st={fade_start}:d={fade_duration}"
|
||||||
|
return filt + f"[{output_label}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_capture_mp4(
|
||||||
|
ffmpeg_exe: Path,
|
||||||
|
capture_path: Path,
|
||||||
|
srt_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
*,
|
||||||
|
music_file: Optional[Path] = None,
|
||||||
|
voiceover_file: Optional[Path] = None,
|
||||||
|
warnings: Optional[List[str]] = None,
|
||||||
|
) -> 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}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
has_music = music_file is not None and music_file.is_file()
|
||||||
|
has_voice = voiceover_file is not None and voiceover_file.is_file()
|
||||||
|
|
||||||
|
fade_start: Optional[float] = None
|
||||||
|
fade_duration: Optional[float] = None
|
||||||
|
if has_music:
|
||||||
|
ffprobe = _resolve_ffprobe_exe()
|
||||||
|
duration = _probe_media_duration_seconds(ffprobe, capture_path)
|
||||||
|
fade_start, fade_duration = _music_fade_params(duration)
|
||||||
|
if fade_start is None and warnings is not None:
|
||||||
|
warnings.append("video_duration_probe_failed_for_music_fade")
|
||||||
|
|
||||||
|
if has_music or has_voice:
|
||||||
|
cmd_tail: List[str] = ["-y", "-i", str(capture_path)]
|
||||||
|
input_idx = 1
|
||||||
|
filter_parts: List[str] = [f"[0:v]{subtitle_filter}[vout]"]
|
||||||
|
audio_labels: List[str] = []
|
||||||
|
|
||||||
|
if has_music:
|
||||||
|
cmd_tail.extend(["-stream_loop", "-1", "-i", str(music_file)])
|
||||||
|
music_label = "aout" if not has_voice else "music"
|
||||||
|
filter_parts.append(
|
||||||
|
_build_music_filter(input_idx, fade_start, fade_duration, music_label)
|
||||||
|
)
|
||||||
|
if has_voice:
|
||||||
|
audio_labels.append(f"[{music_label}]")
|
||||||
|
input_idx += 1
|
||||||
|
|
||||||
|
if has_voice:
|
||||||
|
cmd_tail.extend(["-i", str(voiceover_file)])
|
||||||
|
if has_music:
|
||||||
|
filter_parts.append(
|
||||||
|
f"[{input_idx}:a]volume={_VOICE_VOLUME}[voice]"
|
||||||
|
)
|
||||||
|
audio_labels.append("[voice]")
|
||||||
|
else:
|
||||||
|
# voice-only:apad 补静音,避免 -shortest 按旁白长度截短视频
|
||||||
|
filter_parts.append(
|
||||||
|
f"[{input_idx}:a]volume={_VOICE_VOLUME},apad[aout]"
|
||||||
|
)
|
||||||
|
input_idx += 1
|
||||||
|
|
||||||
|
if len(audio_labels) == 2:
|
||||||
|
filter_parts.append(
|
||||||
|
"".join(audio_labels) + "amix=inputs=2:normalize=0[aout]"
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_tail.extend(
|
||||||
|
[
|
||||||
|
"-filter_complex",
|
||||||
|
";".join(filter_parts),
|
||||||
|
"-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_tail = [
|
||||||
|
"-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(ffmpeg_exe, cmd_tail)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_desktop_record_cmd(ffmpeg_exe: Path, capture_path: str) -> Optional[List[str]]:
|
||||||
|
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return None
|
||||||
|
return _ffmpeg_cmd(
|
||||||
|
ffmpeg_exe,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"gdigrab",
|
||||||
|
"-framerate",
|
||||||
|
str(_CAPTURE_FRAMERATE),
|
||||||
|
"-i",
|
||||||
|
"desktop",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"ultrafast",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
capture_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RpaVideoSession:
|
||||||
|
"""Service/RPA 层统一 ffmpeg 桌面录屏;OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
skill_slug: str,
|
||||||
|
skill_data_dir: str,
|
||||||
|
batch_id: str,
|
||||||
|
title: str = "",
|
||||||
|
closing_title: str = "",
|
||||||
|
emit_activity: bool = True,
|
||||||
|
) -> 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.closing_title = closing_title
|
||||||
|
self.emit_activity = emit_activity
|
||||||
|
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._voiceover_dir = os.path.join(self._artifact_root, "voiceover")
|
||||||
|
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._voiceover_path = os.path.join(self._voiceover_dir, "voiceover.wav")
|
||||||
|
self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
|
||||||
|
|
||||||
|
self._music_path: Optional[str] = None
|
||||||
|
self._voiceover_output: Optional[str] = None
|
||||||
|
|
||||||
|
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._voiceover_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:
|
||||||
|
"""记录一步骤字幕(相对录屏开始时间);可选同步写入 Activity Stream。"""
|
||||||
|
cleaned = text.strip()
|
||||||
|
if self.emit_activity and cleaned:
|
||||||
|
try:
|
||||||
|
from .. import activity
|
||||||
|
|
||||||
|
activity.emit(
|
||||||
|
cleaned,
|
||||||
|
skill=self.skill_slug,
|
||||||
|
stage="rpa.video",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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=cleaned, 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
|
||||||
|
|
||||||
|
ffmpeg_exe = _resolve_ffmpeg_exe()
|
||||||
|
if ffmpeg_exe is None:
|
||||||
|
self.warnings.append("ffmpeg_not_found")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = _build_desktop_record_cmd(ffmpeg_exe, 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 _safe_add_step(self, text: str, *, duration: float = 4.0) -> None:
|
||||||
|
try:
|
||||||
|
self.add_step(text, duration=duration)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "RpaVideoSession":
|
||||||
|
if self.enabled:
|
||||||
|
self._t0 = time.monotonic()
|
||||||
|
self._start_ffmpeg_capture()
|
||||||
|
await asyncio.sleep(_INTRO_STABILIZE_SECONDS)
|
||||||
|
if self.title:
|
||||||
|
await self._safe_add_step(self.title, duration=4.0)
|
||||||
|
intro_wait = _INTRO_BUFFER_SECONDS - _INTRO_STABILIZE_SECONDS
|
||||||
|
if intro_wait > 0:
|
||||||
|
await asyncio.sleep(intro_wait)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||||
|
if self.enabled:
|
||||||
|
if self.closing_title:
|
||||||
|
await self._safe_add_step(self.closing_title, duration=4.0)
|
||||||
|
await asyncio.sleep(_OUTRO_BUFFER_SECONDS)
|
||||||
|
self._stop_ffmpeg_capture()
|
||||||
|
await self.finalize()
|
||||||
|
|
||||||
|
def _try_compose_variants(
|
||||||
|
self,
|
||||||
|
ffmpeg_exe: Path,
|
||||||
|
capture: Path,
|
||||||
|
srt_file: Path,
|
||||||
|
output: Path,
|
||||||
|
*,
|
||||||
|
music: Optional[Path],
|
||||||
|
voiceover: Optional[Path],
|
||||||
|
use_srt: bool,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""按 music/voice 组合尝试合成,失败时逐级降级。"""
|
||||||
|
srt = srt_file if use_srt else Path(self._srt_path)
|
||||||
|
variants: List[tuple[Optional[Path], Optional[Path]]] = [
|
||||||
|
(music, voiceover),
|
||||||
|
]
|
||||||
|
if music and voiceover:
|
||||||
|
variants.append((None, voiceover))
|
||||||
|
if music:
|
||||||
|
variants.append((music, None))
|
||||||
|
variants.append((None, None))
|
||||||
|
|
||||||
|
seen: set[tuple[Optional[str], Optional[str]]] = set()
|
||||||
|
last_err = ""
|
||||||
|
for m, v in variants:
|
||||||
|
key = (str(m) if m else None, str(v) if v else None)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
ok, err = _compose_capture_mp4(
|
||||||
|
ffmpeg_exe,
|
||||||
|
capture,
|
||||||
|
srt,
|
||||||
|
output,
|
||||||
|
music_file=m,
|
||||||
|
voiceover_file=v,
|
||||||
|
warnings=self.warnings,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return True, ""
|
||||||
|
last_err = err
|
||||||
|
if m is not None and v is not None:
|
||||||
|
self.warnings.append(f"ffmpeg_with_music_and_voice_failed: {err}")
|
||||||
|
elif m is not None:
|
||||||
|
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||||||
|
|
||||||
|
return False, last_err
|
||||||
|
|
||||||
|
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(self.warnings)
|
||||||
|
if music is not None:
|
||||||
|
self._music_path = str(music.resolve())
|
||||||
|
elif (
|
||||||
|
"background_music_lfs_pointer_only" not in self.warnings
|
||||||
|
and "background_music_invalid" not in self.warnings
|
||||||
|
):
|
||||||
|
self.warnings.append("background_music_missing")
|
||||||
|
|
||||||
|
ffmpeg_exe = _resolve_ffmpeg_exe()
|
||||||
|
if ffmpeg_exe is None:
|
||||||
|
self.warnings.append("ffmpeg_not_found")
|
||||||
|
self.artifacts = self.summary()
|
||||||
|
return
|
||||||
|
|
||||||
|
voiceover: Optional[Path] = None
|
||||||
|
try:
|
||||||
|
vo = _synthesize_step_voiceover(
|
||||||
|
entries,
|
||||||
|
Path(self._voiceover_path),
|
||||||
|
self.warnings,
|
||||||
|
ffmpeg_exe=ffmpeg_exe,
|
||||||
|
)
|
||||||
|
if vo is not None:
|
||||||
|
voiceover = vo
|
||||||
|
self._voiceover_output = str(vo.resolve())
|
||||||
|
except Exception as exc:
|
||||||
|
self.warnings.append(f"tts_unexpected_error: {exc}")
|
||||||
|
|
||||||
|
srt_file = Path(self._srt_path)
|
||||||
|
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
|
||||||
|
|
||||||
|
ok, err = self._try_compose_variants(
|
||||||
|
ffmpeg_exe,
|
||||||
|
capture,
|
||||||
|
srt_file,
|
||||||
|
Path(self._planned_output),
|
||||||
|
music=music,
|
||||||
|
voiceover=voiceover,
|
||||||
|
use_srt=use_srt,
|
||||||
|
)
|
||||||
|
if not ok and not use_srt:
|
||||||
|
ok, err = _run_ffmpeg(
|
||||||
|
ffmpeg_exe,
|
||||||
|
[
|
||||||
|
"-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
|
||||||
|
)
|
||||||
|
audio_warnings = [
|
||||||
|
w
|
||||||
|
for w in self.warnings
|
||||||
|
if w.startswith(
|
||||||
|
(
|
||||||
|
"tts_",
|
||||||
|
"video_duration_probe_failed",
|
||||||
|
"ffmpeg_with_music",
|
||||||
|
"background_music_",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or w == "background_music_missing"
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"path": self.output_video_path,
|
||||||
|
"capture_path": self.capture_path,
|
||||||
|
"record_log_path": record_log,
|
||||||
|
"warnings": list(self.warnings),
|
||||||
|
"voiceover_path": self._voiceover_output,
|
||||||
|
"music_path": self._music_path,
|
||||||
|
"audio_warnings": audio_warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_artifact_dict(self) -> Dict[str, Any]:
|
||||||
|
data = self.summary()
|
||||||
|
data["batch_id"] = self.batch_id
|
||||||
|
return data
|
||||||
338
src/jiangchang_skill_core/runtime_diagnostics.py
Normal file
338
src/jiangchang_skill_core/runtime_diagnostics.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"""共享 Runtime 诊断:health 输出与 media-assets / ffmpeg 探测。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.metadata
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import jiangchang_skill_core
|
||||||
|
from . import config
|
||||||
|
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
|
||||||
|
from .runtime_env import platform_default_data_root
|
||||||
|
|
||||||
|
_SHARED_CORE_HINT = (
|
||||||
|
"jiangchang_skill_core is loaded from the skill tree; expected the shared "
|
||||||
|
"jiangchang-platform-kit installed in the host Python runtime."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeIssue:
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
severity: str = "warning"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeDiagnostics:
|
||||||
|
skill_slug: str
|
||||||
|
python_executable: str
|
||||||
|
platform_kit_version: str | None
|
||||||
|
platform_kit_min_version: str | None
|
||||||
|
platform_kit_version_ok: bool | None
|
||||||
|
jiangchang_skill_core_file: str | None
|
||||||
|
|
||||||
|
jiangchang_data_root: str | None
|
||||||
|
resolved_data_root: str | None
|
||||||
|
|
||||||
|
media_assets_root: str | None
|
||||||
|
ffmpeg_available: bool
|
||||||
|
ffmpeg_path: str | None
|
||||||
|
|
||||||
|
background_music_mp3_count: int
|
||||||
|
background_music_usable_count: int
|
||||||
|
background_music_issue: str | None
|
||||||
|
background_music_sample_path: str | None
|
||||||
|
|
||||||
|
record_video_enabled: bool
|
||||||
|
issues: tuple[RuntimeIssue, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_fatal_issues(self) -> bool:
|
||||||
|
return any(issue.severity == "error" for issue in self.issues)
|
||||||
|
|
||||||
|
def issue_codes(self) -> list[str]:
|
||||||
|
return [issue.code for issue in self.issues]
|
||||||
|
|
||||||
|
|
||||||
|
def _env_dict(env: Mapping[str, str] | None) -> dict[str, str]:
|
||||||
|
return dict(env) if env is not None else dict(os.environ)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_data_root(env: Mapping[str, str]) -> str:
|
||||||
|
root = (env.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||||
|
if root:
|
||||||
|
return root
|
||||||
|
return platform_default_data_root()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(version: str) -> tuple[int, int, int, int]:
|
||||||
|
"""Parse major.minor.patch[.postN] into (major, minor, patch, post)."""
|
||||||
|
text = version.strip()
|
||||||
|
post = 0
|
||||||
|
if ".post" in text:
|
||||||
|
base, _, post_part = text.partition(".post")
|
||||||
|
post_digits = ""
|
||||||
|
for ch in post_part:
|
||||||
|
if ch.isdigit():
|
||||||
|
post_digits += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
post = int(post_digits) if post_digits else 0
|
||||||
|
text = base
|
||||||
|
|
||||||
|
parts: list[int] = []
|
||||||
|
for piece in text.split("."):
|
||||||
|
digits = ""
|
||||||
|
for ch in piece:
|
||||||
|
if ch.isdigit():
|
||||||
|
digits += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if digits:
|
||||||
|
parts.append(int(digits))
|
||||||
|
|
||||||
|
while len(parts) < 3:
|
||||||
|
parts.append(0)
|
||||||
|
|
||||||
|
return parts[0], parts[1], parts[2], post
|
||||||
|
|
||||||
|
|
||||||
|
def version_ge(installed: str, required: str) -> bool:
|
||||||
|
"""Compare semver-like versions without external deps (supports legacy .postN)."""
|
||||||
|
return _parse_version(installed) >= _parse_version(required)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_under(parent: str | Path, child: str | Path) -> bool:
|
||||||
|
try:
|
||||||
|
parent_path = Path(parent).resolve()
|
||||||
|
child_path = Path(child).resolve()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
parent_norm = os.path.normcase(str(parent_path))
|
||||||
|
child_norm = os.path.normcase(str(child_path))
|
||||||
|
common = os.path.commonpath([parent_norm, child_norm])
|
||||||
|
return common == parent_norm
|
||||||
|
return os.path.commonpath([str(parent_path), str(child_path)]) == str(parent_path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_jiangchang_skill_core_from_skill_tree(
|
||||||
|
*,
|
||||||
|
skill_root: str | Path,
|
||||||
|
core_file: str | Path | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True when jiangchang_skill_core is loaded from inside skill_root."""
|
||||||
|
try:
|
||||||
|
root = Path(skill_root)
|
||||||
|
if not str(root).strip():
|
||||||
|
return False
|
||||||
|
core = Path(core_file) if core_file is not None else Path(jiangchang_skill_core.__file__)
|
||||||
|
return _path_under(root, core)
|
||||||
|
except (OSError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_kit_version() -> str | None:
|
||||||
|
try:
|
||||||
|
return importlib.metadata.version("jiangchang-platform-kit")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_from_env(env: Mapping[str, str], key: str, default: bool = False) -> bool:
|
||||||
|
val = env.get(key)
|
||||||
|
if val is None or val == "":
|
||||||
|
return default
|
||||||
|
return str(val).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_runtime_diagnostics(
|
||||||
|
*,
|
||||||
|
skill_slug: str,
|
||||||
|
platform_kit_min_version: str | None = None,
|
||||||
|
skill_root: str | Path | None = None,
|
||||||
|
record_video: bool | None = None,
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> RuntimeDiagnostics:
|
||||||
|
env_map = _env_dict(env)
|
||||||
|
issues: list[RuntimeIssue] = []
|
||||||
|
|
||||||
|
jiangchang_data_root = env_map.get("JIANGCHANG_DATA_ROOT")
|
||||||
|
resolved_data_root = _resolve_data_root(env_map)
|
||||||
|
|
||||||
|
media_root_path = resolve_media_assets_root(resolved_data_root, env_map)
|
||||||
|
media_status = probe_media_assets(resolved_data_root, env_map)
|
||||||
|
ffmpeg_path_obj = media_status.ffmpeg_path
|
||||||
|
ffmpeg_ok = ffmpeg_path_obj is not None and ffmpeg_path_obj.is_file()
|
||||||
|
|
||||||
|
music_probe = probe_background_music(media_root_path, env=env_map)
|
||||||
|
mp3_count = int(music_probe["mp3_count"])
|
||||||
|
usable_count = int(music_probe["usable_count"])
|
||||||
|
music_issue = music_probe.get("issue")
|
||||||
|
music_sample = music_probe.get("sample_path")
|
||||||
|
|
||||||
|
if music_issue is None and not media_status.music_ready and media_status.warnings:
|
||||||
|
for warning in media_status.warnings:
|
||||||
|
if warning.startswith("background_music") or warning == "music_dir_missing":
|
||||||
|
music_issue = warning
|
||||||
|
break
|
||||||
|
|
||||||
|
platform_version = _platform_kit_version()
|
||||||
|
platform_ok: bool | None
|
||||||
|
if platform_kit_min_version is None:
|
||||||
|
platform_ok = None
|
||||||
|
elif platform_version is None:
|
||||||
|
platform_ok = False
|
||||||
|
else:
|
||||||
|
platform_ok = version_ge(platform_version, platform_kit_min_version)
|
||||||
|
|
||||||
|
if platform_version is None:
|
||||||
|
issues.append(
|
||||||
|
RuntimeIssue(
|
||||||
|
code="platform_kit_not_installed",
|
||||||
|
message="jiangchang-platform-kit package metadata not found",
|
||||||
|
severity="error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif platform_kit_min_version is not None and not platform_ok:
|
||||||
|
issues.append(
|
||||||
|
RuntimeIssue(
|
||||||
|
code="platform_kit_version_low",
|
||||||
|
message=(
|
||||||
|
f"jiangchang-platform-kit version {platform_version} "
|
||||||
|
f"is below required {platform_kit_min_version}"
|
||||||
|
),
|
||||||
|
severity="error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
core_file: str | None
|
||||||
|
try:
|
||||||
|
core_file = os.path.abspath(jiangchang_skill_core.__file__)
|
||||||
|
except (OSError, TypeError, ValueError):
|
||||||
|
core_file = None
|
||||||
|
|
||||||
|
if skill_root is not None and core_file is not None:
|
||||||
|
if is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=core_file):
|
||||||
|
issues.append(
|
||||||
|
RuntimeIssue(
|
||||||
|
code="jiangchang_skill_core_loaded_from_skill_tree",
|
||||||
|
message=_SHARED_CORE_HINT,
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if record_video is None:
|
||||||
|
if env is not None:
|
||||||
|
record_video_enabled = _bool_from_env(env_map, "OPENCLAW_RECORD_VIDEO", False)
|
||||||
|
else:
|
||||||
|
record_video_enabled = config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
|
||||||
|
else:
|
||||||
|
record_video_enabled = record_video
|
||||||
|
|
||||||
|
if not ffmpeg_ok:
|
||||||
|
severity = "warning"
|
||||||
|
message = "ffmpeg not available"
|
||||||
|
if record_video_enabled:
|
||||||
|
message = "OPENCLAW_RECORD_VIDEO enabled but ffmpeg is not available"
|
||||||
|
issues.append(
|
||||||
|
RuntimeIssue(
|
||||||
|
code="ffmpeg_unavailable",
|
||||||
|
message=message,
|
||||||
|
severity=severity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if record_video_enabled and music_issue:
|
||||||
|
issues.append(
|
||||||
|
RuntimeIssue(
|
||||||
|
code="background_music_unavailable",
|
||||||
|
message=f"background music unavailable: {music_issue}",
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RuntimeDiagnostics(
|
||||||
|
skill_slug=skill_slug,
|
||||||
|
python_executable=sys.executable,
|
||||||
|
platform_kit_version=platform_version,
|
||||||
|
platform_kit_min_version=platform_kit_min_version,
|
||||||
|
platform_kit_version_ok=platform_ok,
|
||||||
|
jiangchang_skill_core_file=core_file,
|
||||||
|
jiangchang_data_root=jiangchang_data_root,
|
||||||
|
resolved_data_root=resolved_data_root,
|
||||||
|
media_assets_root=str(media_root_path),
|
||||||
|
ffmpeg_available=ffmpeg_ok,
|
||||||
|
ffmpeg_path=str(ffmpeg_path_obj) if ffmpeg_path_obj else None,
|
||||||
|
background_music_mp3_count=mp3_count,
|
||||||
|
background_music_usable_count=usable_count,
|
||||||
|
background_music_issue=str(music_issue) if music_issue else None,
|
||||||
|
background_music_sample_path=str(music_sample) if music_sample else None,
|
||||||
|
record_video_enabled=record_video_enabled,
|
||||||
|
issues=tuple(issues),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
|
||||||
|
lines = [
|
||||||
|
f"skill_slug: {diagnostics.skill_slug}",
|
||||||
|
f"python_executable: {diagnostics.python_executable}",
|
||||||
|
f"platform_kit_version: {diagnostics.platform_kit_version or ''}",
|
||||||
|
f"platform_kit_min_version: {diagnostics.platform_kit_min_version or ''}",
|
||||||
|
f"platform_kit_version_ok: {diagnostics.platform_kit_version_ok}",
|
||||||
|
f"jiangchang_skill_core_file: {diagnostics.jiangchang_skill_core_file or ''}",
|
||||||
|
f"JIANGCHANG_DATA_ROOT: {diagnostics.jiangchang_data_root or ''}",
|
||||||
|
f"resolved_data_root: {diagnostics.resolved_data_root or ''}",
|
||||||
|
f"media_assets_root: {diagnostics.media_assets_root or ''}",
|
||||||
|
f"ffmpeg_available: {diagnostics.ffmpeg_available}",
|
||||||
|
f"ffmpeg_path: {diagnostics.ffmpeg_path or ''}",
|
||||||
|
f"background_music_mp3_count: {diagnostics.background_music_mp3_count}",
|
||||||
|
f"background_music_usable_count: {diagnostics.background_music_usable_count}",
|
||||||
|
f"background_music_issue: {diagnostics.background_music_issue or ''}",
|
||||||
|
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
|
||||||
|
f"record_video_enabled: {diagnostics.record_video_enabled}",
|
||||||
|
]
|
||||||
|
for issue in diagnostics.issues:
|
||||||
|
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"skill_slug": diagnostics.skill_slug,
|
||||||
|
"python_executable": diagnostics.python_executable,
|
||||||
|
"platform_kit_version": diagnostics.platform_kit_version,
|
||||||
|
"platform_kit_min_version": diagnostics.platform_kit_min_version,
|
||||||
|
"platform_kit_version_ok": diagnostics.platform_kit_version_ok,
|
||||||
|
"jiangchang_skill_core_file": diagnostics.jiangchang_skill_core_file,
|
||||||
|
"JIANGCHANG_DATA_ROOT": diagnostics.jiangchang_data_root,
|
||||||
|
"resolved_data_root": diagnostics.resolved_data_root,
|
||||||
|
"media_assets_root": diagnostics.media_assets_root,
|
||||||
|
"ffmpeg_available": diagnostics.ffmpeg_available,
|
||||||
|
"ffmpeg_path": diagnostics.ffmpeg_path,
|
||||||
|
"background_music_mp3_count": diagnostics.background_music_mp3_count,
|
||||||
|
"background_music_usable_count": diagnostics.background_music_usable_count,
|
||||||
|
"background_music_issue": diagnostics.background_music_issue,
|
||||||
|
"background_music_sample_path": diagnostics.background_music_sample_path,
|
||||||
|
"record_video_enabled": diagnostics.record_video_enabled,
|
||||||
|
"runtime_issues": [
|
||||||
|
{"code": issue.code, "message": issue.message, "severity": issue.severity}
|
||||||
|
for issue in diagnostics.issues
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_runtime_diagnostics(
|
||||||
|
diagnostics: RuntimeDiagnostics,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
level: int = logging.INFO,
|
||||||
|
) -> None:
|
||||||
|
log = logger or logging.getLogger("jiangchang_skill_core.runtime_diagnostics")
|
||||||
|
for line in format_runtime_health_lines(diagnostics):
|
||||||
|
log.log(level, line)
|
||||||
@@ -44,6 +44,20 @@ def get_user_id() -> str:
|
|||||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||||
|
|
||||||
|
|
||||||
|
def get_runs_dir() -> str:
|
||||||
|
"""Run Journal 目录:{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/"""
|
||||||
|
return os.path.join(get_data_root(), ".jiangchang", "runs")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runs_dir() -> str:
|
||||||
|
path = get_runs_dir()
|
||||||
|
try:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_skills_root(path: str) -> bool:
|
def _looks_like_skills_root(path: str) -> bool:
|
||||||
if not path or not os.path.isdir(path):
|
if not path or not os.path.isdir(path):
|
||||||
return False
|
return False
|
||||||
@@ -55,6 +69,7 @@ def _looks_like_skills_root(path: str) -> bool:
|
|||||||
"toutiao-publisher",
|
"toutiao-publisher",
|
||||||
"logistics-tracker",
|
"logistics-tracker",
|
||||||
"api-key-vault",
|
"api-key-vault",
|
||||||
|
"receive-order",
|
||||||
):
|
):
|
||||||
if os.path.isdir(os.path.join(path, marker)):
|
if os.path.isdir(os.path.join(path, marker)):
|
||||||
return True
|
return True
|
||||||
@@ -67,15 +82,13 @@ def get_skills_root() -> str:
|
|||||||
|
|
||||||
优先级:
|
优先级:
|
||||||
1) JIANGCHANG_SKILLS_ROOT
|
1) JIANGCHANG_SKILLS_ROOT
|
||||||
2) CLAW_SKILLS_ROOT
|
2) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||||
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
3) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||||
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
4) 其他平台:~/.openclaw/skills
|
||||||
5) 其他平台:~/.openclaw/skills
|
|
||||||
"""
|
"""
|
||||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
|
||||||
v = (os.getenv(key) or "").strip()
|
if v:
|
||||||
if v:
|
return os.path.normpath(v)
|
||||||
return os.path.normpath(v)
|
|
||||||
|
|
||||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||||
if sys.platform == "win32" and not app:
|
if sys.platform == "win32" and not app:
|
||||||
@@ -101,8 +114,8 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
|||||||
"""
|
"""
|
||||||
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
|
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
|
||||||
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
|
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
|
||||||
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
避免全局 JIANGCHANG_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
||||||
推断失败时再读上述环境变量,最后回落 get_skills_root()。
|
推断失败时再读 JIANGCHANG_SKILLS_ROOT,最后回落 get_skills_root()。
|
||||||
"""
|
"""
|
||||||
if skill_scripts_dir:
|
if skill_scripts_dir:
|
||||||
scripts = os.path.abspath(skill_scripts_dir)
|
scripts = os.path.abspath(skill_scripts_dir)
|
||||||
@@ -111,10 +124,9 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
|||||||
if _looks_like_skills_root(inferred):
|
if _looks_like_skills_root(inferred):
|
||||||
return os.path.normpath(inferred)
|
return os.path.normpath(inferred)
|
||||||
|
|
||||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
|
||||||
v = (os.getenv(key) or "").strip()
|
if v:
|
||||||
if v:
|
return os.path.normpath(v)
|
||||||
return os.path.normpath(v)
|
|
||||||
|
|
||||||
return get_skills_root()
|
return get_skills_root()
|
||||||
|
|
||||||
@@ -134,3 +146,74 @@ def apply_cli_local_defaults() -> None:
|
|||||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||||
|
def find_chrome_executable() -> "str | None":
|
||||||
|
"""查找系统已安装的 Chrome 或 Edge 可执行文件路径。
|
||||||
|
|
||||||
|
查找顺序:
|
||||||
|
1. 常见文件路径(PROGRAMFILES、LOCALAPPDATA、macOS、Linux)
|
||||||
|
2. Windows 注册表 App Paths(HKLM 优先,HKCU 其次)
|
||||||
|
3. PATH shutil.which 兜底
|
||||||
|
|
||||||
|
返回找到的路径字符串,找不到返回 None。
|
||||||
|
宿主/技能调用 Playwright launch 时应传 executable_path=find_chrome_executable()
|
||||||
|
而非 channel="chrome",以避免 Playwright 内部检测在系统级安装时失效。
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
||||||
|
prog_files = os.environ.get("PROGRAMFILES", r"C:\Program Files")
|
||||||
|
prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")
|
||||||
|
candidates = [
|
||||||
|
os.path.join(prog_files, r"Google\Chrome\Application\chrome.exe"),
|
||||||
|
os.path.join(prog_files_x86, r"Google\Chrome\Application\chrome.exe"),
|
||||||
|
os.path.join(local_app_data, r"Google\Chrome\Application\chrome.exe"),
|
||||||
|
os.path.join(prog_files, r"Microsoft\Edge\Application\msedge.exe"),
|
||||||
|
os.path.join(prog_files_x86, r"Microsoft\Edge\Application\msedge.exe"),
|
||||||
|
os.path.join(local_app_data, r"Microsoft\Edge\Application\msedge.exe"),
|
||||||
|
]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
candidates = [
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||||
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
candidates = [
|
||||||
|
"/usr/bin/google-chrome-stable",
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
"/usr/bin/chromium-browser",
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
"/usr/bin/microsoft-edge",
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in candidates:
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Windows 注册表兜底(覆盖系统级 HKLM 安装)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||||
|
for reg_path in (
|
||||||
|
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe",
|
||||||
|
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(hive, reg_path) as key:
|
||||||
|
val, _ = winreg.QueryValueEx(key, "")
|
||||||
|
if val and os.path.isfile(val):
|
||||||
|
return val
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# PATH 兜底
|
||||||
|
for name in ("google-chrome-stable", "google-chrome", "chrome", "msedge", "chromium"):
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
return None
|
||||||
|
|||||||
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
兄弟技能 CLI 桥:同步 subprocess 调用并列技能 scripts/main.py。
|
||||||
|
|
||||||
|
account-manager 等兄弟技能为同步 CLI,非 async、非线程池。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any, List, Optional, Sequence
|
||||||
|
|
||||||
|
from .runtime_env import get_skills_root
|
||||||
|
from .unified_logging import subprocess_env_with_trace
|
||||||
|
|
||||||
|
|
||||||
|
def get_sibling_main_path(skill_slug: str) -> str:
|
||||||
|
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _python_exe() -> str:
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sibling(skill_slug: str, argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
main_path = get_sibling_main_path(skill_slug)
|
||||||
|
cmd = [_python_exe(), main_path, *argv]
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=subprocess_env_with_trace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_from_stdout(stdout: str) -> Any:
|
||||||
|
for line in reversed((stdout or "").splitlines()):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("ERROR:"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return json.loads(stripped)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call_sibling_json(skill_slug: str, argv: Sequence[str]) -> dict | None:
|
||||||
|
"""Run sibling CLI; return last JSON object line from stdout, or None."""
|
||||||
|
result = _run_sibling(skill_slug, argv)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call_sibling_json_array(skill_slug: str, argv: Sequence[str]) -> list | None:
|
||||||
|
"""Run sibling CLI; return last JSON array line from stdout, or None."""
|
||||||
|
result = _run_sibling(skill_slug, argv)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
@@ -10,17 +10,23 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from logging.handlers import TimedRotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from .runtime_env import get_data_root, get_user_id
|
from .runtime_env import get_data_root, get_user_id
|
||||||
|
|
||||||
_skill_slug: str = ""
|
_skill_slug: str = ""
|
||||||
_logger_name: str = ""
|
_logger_name: str = ""
|
||||||
|
|
||||||
|
# 避免 logging 内部错误再向 stderr 打印 "--- Logging error ---"
|
||||||
|
logging.raiseExceptions = False
|
||||||
|
|
||||||
|
|
||||||
def get_unified_logs_dir() -> str:
|
def get_unified_logs_dir() -> str:
|
||||||
path = os.path.join(get_data_root(), get_user_id(), "logs")
|
path = os.path.join(get_data_root(), get_user_id(), "logs")
|
||||||
os.makedirs(path, exist_ok=True)
|
try:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
@@ -30,11 +36,27 @@ def get_skill_log_file_path() -> str:
|
|||||||
if override:
|
if override:
|
||||||
parent = os.path.dirname(os.path.abspath(override))
|
parent = os.path.dirname(os.path.abspath(override))
|
||||||
if parent:
|
if parent:
|
||||||
os.makedirs(parent, exist_ok=True)
|
try:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return os.path.abspath(override)
|
return os.path.abspath(override)
|
||||||
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
|
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:
|
def ensure_trace_for_process() -> str:
|
||||||
"""本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。"""
|
"""本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。"""
|
||||||
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||||
@@ -81,44 +103,72 @@ def _backup_count() -> int:
|
|||||||
return 30
|
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:
|
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
||||||
"""
|
"""
|
||||||
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
|
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
|
||||||
须在进程早期调用(如 CLI main 在业务日志之前)。
|
须在进程早期调用(如 CLI main 在业务日志之前)。
|
||||||
|
文件日志不可写时降级为 NullHandler,不影响 CLI 返回码。
|
||||||
"""
|
"""
|
||||||
global _skill_slug, _logger_name
|
global _skill_slug, _logger_name
|
||||||
|
logging.raiseExceptions = False
|
||||||
ensure_trace_for_process()
|
ensure_trace_for_process()
|
||||||
_skill_slug = skill_slug
|
_skill_slug = skill_slug
|
||||||
_logger_name = logger_name
|
_logger_name = logger_name
|
||||||
|
|
||||||
log = logging.getLogger(logger_name)
|
log = logging.getLogger(logger_name)
|
||||||
if log.handlers:
|
_clear_logger_handlers(log)
|
||||||
return
|
|
||||||
log.setLevel(_log_level_from_env())
|
log.setLevel(_log_level_from_env())
|
||||||
path = get_skill_log_file_path()
|
|
||||||
fh = TimedRotatingFileHandler(
|
|
||||||
path,
|
|
||||||
when="midnight",
|
|
||||||
interval=1,
|
|
||||||
backupCount=_backup_count(),
|
|
||||||
encoding="utf-8",
|
|
||||||
delay=True,
|
|
||||||
)
|
|
||||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||||
fh.setFormatter(fmt)
|
filt = _SkillContextFilter()
|
||||||
fh.addFilter(_SkillContextFilter())
|
|
||||||
log.addHandler(fh)
|
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 (
|
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||||||
"1",
|
"1",
|
||||||
"true",
|
"true",
|
||||||
"yes",
|
"yes",
|
||||||
"on",
|
"on",
|
||||||
):
|
):
|
||||||
sh = logging.StreamHandler(sys.stderr)
|
try:
|
||||||
sh.setLevel(logging.WARNING)
|
sh = logging.StreamHandler(sys.stderr)
|
||||||
sh.setFormatter(fmt)
|
sh.setLevel(logging.WARNING)
|
||||||
sh.addFilter(_SkillContextFilter())
|
sh.setFormatter(fmt)
|
||||||
log.addHandler(sh)
|
sh.addFilter(filt)
|
||||||
|
log.addHandler(sh)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
log.propagate = False
|
log.propagate = False
|
||||||
|
|
||||||
|
|
||||||
@@ -139,18 +189,25 @@ def attach_unified_file_handler(
|
|||||||
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
|
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
|
||||||
"""
|
"""
|
||||||
global _skill_slug
|
global _skill_slug
|
||||||
|
logging.raiseExceptions = False
|
||||||
ensure_trace_for_process()
|
ensure_trace_for_process()
|
||||||
_skill_slug = skill_slug
|
_skill_slug = skill_slug
|
||||||
lg = logging.getLogger(logger_name)
|
lg = logging.getLogger(logger_name)
|
||||||
lg.handlers.clear()
|
_clear_logger_handlers(lg)
|
||||||
lg.setLevel(level)
|
lg.setLevel(level)
|
||||||
parent = os.path.dirname(os.path.abspath(log_path))
|
|
||||||
if parent:
|
|
||||||
os.makedirs(parent, exist_ok=True)
|
|
||||||
fh = logging.FileHandler(log_path, encoding="utf-8")
|
|
||||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||||
fh.setFormatter(fmt)
|
filt = _SkillContextFilter()
|
||||||
fh.addFilter(_SkillContextFilter())
|
|
||||||
lg.addHandler(fh)
|
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
|
lg.propagate = False
|
||||||
return lg
|
return lg
|
||||||
|
|||||||
3
src/screencast/__init__.py
Normal file
3
src/screencast/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .runner import run_screencast
|
||||||
|
|
||||||
|
__all__ = ["run_screencast"]
|
||||||
174
src/screencast/composer.py
Normal file
174
src/screencast/composer.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""FFmpeg 合成:帧序列 + SRT 字幕 + 背景音乐 → MP4。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jiangchang_skill_core.media_assets import (
|
||||||
|
background_music_issue,
|
||||||
|
pick_background_music,
|
||||||
|
resolve_ffmpeg,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 匠厂统一字幕样式 —— 所有 skill 录屏共用,做品牌一致性
|
||||||
|
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||||
|
"FontName=Arial,FontSize=14,"
|
||||||
|
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||||||
|
"Outline=2,Shadow=1,Alignment=2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComposeVideoResult:
|
||||||
|
output_path: str
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_srt_path(path: str) -> str:
|
||||||
|
return path.replace("\\", "/").replace(":", "\\:")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||||||
|
cmd = [str(ffmpeg_exe), *cmd_tail]
|
||||||
|
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 _media_assets_env(
|
||||||
|
media_assets_root: str | Path | None,
|
||||||
|
media_assets_env: dict[str, str] | None,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
if media_assets_root is None and media_assets_env is None:
|
||||||
|
return None
|
||||||
|
env = dict(media_assets_env or {})
|
||||||
|
if media_assets_root is not None:
|
||||||
|
env["MEDIA_ASSETS_ROOT"] = str(media_assets_root)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def compose_video(
|
||||||
|
frames_dir: str,
|
||||||
|
fps: int,
|
||||||
|
subtitle_path: str,
|
||||||
|
output_path: str,
|
||||||
|
*,
|
||||||
|
music_volume: float = 0.15,
|
||||||
|
allow_no_music: bool = True,
|
||||||
|
media_assets_root: str | Path | None = None,
|
||||||
|
media_assets_env: dict[str, str] | None = None,
|
||||||
|
) -> ComposeVideoResult:
|
||||||
|
"""合成 MP4;ffmpeg 与背景音乐均走 jiangchang_skill_core.media_assets。"""
|
||||||
|
frames_dir_path = Path(frames_dir)
|
||||||
|
subtitle_path_obj = Path(subtitle_path)
|
||||||
|
output_path_obj = Path(output_path)
|
||||||
|
output_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
assets_env = _media_assets_env(media_assets_root, media_assets_env)
|
||||||
|
|
||||||
|
ffmpeg_exe = resolve_ffmpeg(env=assets_env)
|
||||||
|
if ffmpeg_exe is None or not Path(ffmpeg_exe).is_file():
|
||||||
|
raise RuntimeError("ffmpeg_not_found")
|
||||||
|
|
||||||
|
ffmpeg_path = Path(ffmpeg_exe)
|
||||||
|
music_file = pick_background_music(env=assets_env)
|
||||||
|
if music_file is None:
|
||||||
|
issue = background_music_issue(env=assets_env)
|
||||||
|
if issue:
|
||||||
|
warnings.append(issue)
|
||||||
|
else:
|
||||||
|
warnings.append("background_music_missing")
|
||||||
|
if not allow_no_music:
|
||||||
|
raise FileNotFoundError(warnings[-1])
|
||||||
|
|
||||||
|
srt_esc = _escape_srt_path(str(subtitle_path_obj.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_tail = [
|
||||||
|
"-y",
|
||||||
|
"-framerate",
|
||||||
|
str(fps),
|
||||||
|
"-i",
|
||||||
|
str(frames_dir_path / "frame_%06d.png"),
|
||||||
|
"-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_obj),
|
||||||
|
]
|
||||||
|
ok, err = _run_ffmpeg(ffmpeg_path, cmd_tail)
|
||||||
|
if not ok:
|
||||||
|
warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||||||
|
if not allow_no_music:
|
||||||
|
raise RuntimeError(f"ffmpeg_compose_failed: {err}")
|
||||||
|
music_file = None
|
||||||
|
else:
|
||||||
|
print(f"[screencast] 背景音乐: {music_file.name}")
|
||||||
|
print(f"[screencast] 输出: {output_path_obj}")
|
||||||
|
return ComposeVideoResult(str(output_path_obj.resolve()), warnings)
|
||||||
|
|
||||||
|
cmd_tail = [
|
||||||
|
"-y",
|
||||||
|
"-framerate",
|
||||||
|
str(fps),
|
||||||
|
"-i",
|
||||||
|
str(frames_dir_path / "frame_%06d.png"),
|
||||||
|
"-vf",
|
||||||
|
subtitle_filter,
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"fast",
|
||||||
|
"-crf",
|
||||||
|
"23",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
str(output_path_obj),
|
||||||
|
]
|
||||||
|
print("[screencast] 运行 FFmpeg 合成(无背景音乐)...")
|
||||||
|
ok, err = _run_ffmpeg(ffmpeg_path, cmd_tail)
|
||||||
|
if not ok:
|
||||||
|
print(err, file=sys.stderr)
|
||||||
|
raise RuntimeError(f"ffmpeg_compose_failed: {err}")
|
||||||
|
|
||||||
|
print(f"[screencast] 输出: {output_path_obj}")
|
||||||
|
return ComposeVideoResult(str(output_path_obj.resolve()), warnings)
|
||||||
63
src/screencast/recorder.py
Normal file
63
src/screencast/recorder.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""全屏截帧引擎,后台线程持续截图存为 PNG 帧序列。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import mss
|
||||||
|
import mss.tools
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenRecorder:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
frames_dir: str,
|
||||||
|
fps: int = 10,
|
||||||
|
monitor_index: Optional[int] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
monitor_index: mss 显示器索引;None=monitors[0](所有屏合并区域,
|
||||||
|
旧行为),1=主屏,2+=第 N 屏。多显示器场景推荐显式传 1。
|
||||||
|
"""
|
||||||
|
self._frames_dir = Path(frames_dir)
|
||||||
|
self._fps = fps
|
||||||
|
self._monitor_index = monitor_index
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._frame_count = 0
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._frame_count = 0
|
||||||
|
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=10)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_count(self) -> int:
|
||||||
|
return self._frame_count
|
||||||
|
|
||||||
|
def _capture_loop(self) -> None:
|
||||||
|
interval = 1.0 / self._fps
|
||||||
|
with mss.mss() as sct:
|
||||||
|
idx = self._monitor_index if self._monitor_index is not None else 0
|
||||||
|
if idx < 0 or idx >= len(sct.monitors):
|
||||||
|
idx = 0 # 越界兜底
|
||||||
|
monitor = sct.monitors[idx]
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
img = sct.grab(monitor)
|
||||||
|
path = self._frames_dir / f"frame_{self._frame_count:06d}.png"
|
||||||
|
mss.tools.to_png(img.rgb, img.size, output=str(path))
|
||||||
|
self._frame_count += 1
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
sleep = max(0.0, interval - elapsed)
|
||||||
|
time.sleep(sleep)
|
||||||
146
src/screencast/runner.py
Normal file
146
src/screencast/runner.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""主编排器:录屏 + 运行 pytest + 生成字幕 + FFmpeg 合成。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sdk_on_path() -> None:
|
||||||
|
"""pip 安装后应能直接 import;仅源码开发态才把 src 加入 sys.path。"""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
if importlib.util.find_spec("jiangchang_desktop_sdk") is not None:
|
||||||
|
return
|
||||||
|
# src/screencast/runner.py → src/screencast → src
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
src = here.parent.parent
|
||||||
|
sdk_dir = src / "jiangchang_desktop_sdk"
|
||||||
|
if sdk_dir.is_dir():
|
||||||
|
src_str = str(src)
|
||||||
|
if src_str not in sys.path:
|
||||||
|
sys.path.insert(0, src_str)
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_sdk_on_path()
|
||||||
|
from jiangchang_desktop_sdk.window_win32 import activate_and_maximize_jiangchang_window # noqa: E402
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from .composer import compose_video
|
||||||
|
from .recorder import ScreenRecorder
|
||||||
|
from .subtitle import SubtitleEngine
|
||||||
|
|
||||||
|
|
||||||
|
def run_screencast(
|
||||||
|
skill_slug: str,
|
||||||
|
subtitle_script: List[Tuple[str, str]],
|
||||||
|
pytest_args: List[str],
|
||||||
|
output_dir: str,
|
||||||
|
media_assets_root: Optional[str] = None,
|
||||||
|
music_subdir: str = "music",
|
||||||
|
fps: int = 10,
|
||||||
|
activate_window: bool = True,
|
||||||
|
monitor_index: Optional[int] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
录制一次技能桌面 E2E 演示视频。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_slug: 技能唯一标识,如 "query-balance-icbc"
|
||||||
|
subtitle_script: [(关键词, 字幕文案), ...] 按顺序匹配 pytest stdout
|
||||||
|
pytest_args: 传递给 pytest 的参数列表
|
||||||
|
output_dir: 最终 MP4 输出目录
|
||||||
|
media_assets_root: media-assets 根目录;不传时使用
|
||||||
|
``resolve_media_assets_root()`` 默认路径
|
||||||
|
(JIANGCHANG_DATA_ROOT/shared/media-assets 或 MEDIA_ASSETS_ROOT)
|
||||||
|
music_subdir: **已废弃**,保留仅为兼容旧调用;背景音乐由
|
||||||
|
``media_assets.pick_background_music()`` 统一解析
|
||||||
|
fps: 截帧帧率,默认 10
|
||||||
|
activate_window: 录屏开始前是否自动激活+最大化匠厂窗口(默认 True;
|
||||||
|
Windows 走 ctypes,非 Windows 走 jiangchang:// 协议)
|
||||||
|
monitor_index: 指定录哪个显示器;None=所有显示器合并(旧行为),
|
||||||
|
1=主屏,2+=第 N 屏(推荐多显示器场景显式传 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
输出 MP4 的绝对路径
|
||||||
|
"""
|
||||||
|
if music_subdir != "music":
|
||||||
|
warnings.warn(
|
||||||
|
"music_subdir is deprecated and ignored; "
|
||||||
|
"background music is resolved via media_assets.pick_background_music()",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_dir_path = Path(output_dir)
|
||||||
|
output_dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
base_name = f"{skill_slug}_{date_str}"
|
||||||
|
output_mp4 = output_dir_path / f"{base_name}.mp4"
|
||||||
|
subtitle_file = output_dir_path / f"{base_name}.srt"
|
||||||
|
|
||||||
|
engine = SubtitleEngine(subtitle_script)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="screencast_frames_") as frames_dir:
|
||||||
|
recorder = ScreenRecorder(frames_dir, fps=fps, monitor_index=monitor_index)
|
||||||
|
|
||||||
|
if activate_window:
|
||||||
|
ok = activate_and_maximize_jiangchang_window()
|
||||||
|
if ok:
|
||||||
|
print("[screencast] 已激活+最大化匠厂窗口")
|
||||||
|
time.sleep(2) # 等窗口浮起稳定
|
||||||
|
else:
|
||||||
|
print("[screencast] 未能激活匠厂窗口,继续录制(可能录到桌面边角)")
|
||||||
|
|
||||||
|
print(f"[screencast] 启动录制 → {output_mp4.name}")
|
||||||
|
recorder.start()
|
||||||
|
engine.set_start_time(time.time())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Windows 子进程 stdout 默认走系统 ANSI code page(中文 Windows 是 cp936/GBK),
|
||||||
|
# 这边用 encoding="utf-8" 解码会乱码。注入 PYTHONIOENCODING + PYTHONUTF8 让
|
||||||
|
# 子进程 Python 强制以 UTF-8 输出,两端编码对齐。非 Windows 默认就 UTF-8,无副作用。
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
env["PYTHONUTF8"] = "1"
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "pytest", *pytest_args],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
assert proc.stdout is not None
|
||||||
|
for line in proc.stdout:
|
||||||
|
sys.stdout.write(line)
|
||||||
|
sys.stdout.flush()
|
||||||
|
engine.process_line(line)
|
||||||
|
exit_code = proc.wait()
|
||||||
|
if exit_code != 0:
|
||||||
|
print(f"[screencast] pytest 退出码 {exit_code},录制仍继续合成")
|
||||||
|
finally:
|
||||||
|
recorder.stop()
|
||||||
|
print(f"[screencast] 录制停止,共 {recorder.frame_count} 帧")
|
||||||
|
|
||||||
|
engine.generate_srt(str(subtitle_file))
|
||||||
|
print(f"[screencast] 字幕 → {subtitle_file.name}")
|
||||||
|
|
||||||
|
compose_video(
|
||||||
|
frames_dir=frames_dir,
|
||||||
|
fps=fps,
|
||||||
|
subtitle_path=str(subtitle_file),
|
||||||
|
output_path=str(output_mp4),
|
||||||
|
media_assets_root=media_assets_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(output_mp4)
|
||||||
94
src/screencast/subtitle.py
Normal file
94
src/screencast/subtitle.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""字幕引擎:根据 stdout 关键词打时间戳,生成 SRT 字幕文件。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Entry:
|
||||||
|
start: float # 距录制开始的秒数
|
||||||
|
text: str
|
||||||
|
duration: float = 5.0 # 每条字幕默认显示 5 秒
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
script: List[Tuple],
|
||||||
|
default_duration: float = 5.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
script: [(关键词, 字幕文案) | (关键词, 字幕文案, 显示时长秒), ...]
|
||||||
|
- 二元组:用 default_duration(向后兼容)
|
||||||
|
- 三元组:本条字幕独立指定显示秒数(用于长等待场景延长字幕停留)
|
||||||
|
关键词大小写不敏感,每个关键词只触发一次。
|
||||||
|
"""
|
||||||
|
self._script = script
|
||||||
|
self._default_duration = default_duration
|
||||||
|
self._entries: List[_Entry] = []
|
||||||
|
self._matched: set[str] = set()
|
||||||
|
self._t0: float = 0.0
|
||||||
|
|
||||||
|
def set_start_time(self, t: float) -> None:
|
||||||
|
self._t0 = t
|
||||||
|
|
||||||
|
def process_line(self, line: str) -> None:
|
||||||
|
lower = line.lower()
|
||||||
|
for entry_tuple in self._script:
|
||||||
|
if len(entry_tuple) == 2:
|
||||||
|
keyword, text = entry_tuple
|
||||||
|
duration = self._default_duration
|
||||||
|
elif len(entry_tuple) == 3:
|
||||||
|
keyword, text, duration = entry_tuple
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if keyword in self._matched:
|
||||||
|
continue
|
||||||
|
if keyword.lower() in lower:
|
||||||
|
elapsed = time.time() - self._t0
|
||||||
|
self._entries.append(_Entry(start=elapsed, text=text, duration=float(duration)))
|
||||||
|
self._matched.add(keyword)
|
||||||
|
|
||||||
|
def generate_srt(self, output_path: str) -> None:
|
||||||
|
"""生成 SRT 文件,自动处理字幕重叠。
|
||||||
|
|
||||||
|
字幕重叠修复策略(避免屏幕上同时显示 2+ 条叠成两行):
|
||||||
|
- 字幕按 start 时间升序排序后;
|
||||||
|
- 相邻字幕保留 GAP=0.1s 间隔;
|
||||||
|
- 每条字幕保证至少 MIN_DURATION=1.0s 显示时间(短到这个值仍读不完是字幕脚本设计问题);
|
||||||
|
- 若当前条让位给下一条后剩余时间 < MIN_DURATION,则把下一条 start 往后推。
|
||||||
|
这样任意时刻屏幕上最多 1 条字幕。
|
||||||
|
"""
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# 防重叠 post-process
|
||||||
|
MIN_DURATION = 1.0
|
||||||
|
GAP = 0.1
|
||||||
|
entries = sorted(self._entries, key=lambda e: e.start)
|
||||||
|
for i in range(len(entries) - 1):
|
||||||
|
cur, nxt = entries[i], entries[i + 1]
|
||||||
|
max_end = nxt.start - GAP
|
||||||
|
new_dur = max_end - cur.start
|
||||||
|
if new_dur < MIN_DURATION:
|
||||||
|
# 太挤:cur 至少撑 MIN_DURATION 秒,把 nxt 推迟
|
||||||
|
cur.duration = MIN_DURATION
|
||||||
|
nxt.start = cur.start + MIN_DURATION + GAP
|
||||||
|
else:
|
||||||
|
# 正常让位:cur 显示到 (nxt.start - GAP)
|
||||||
|
cur.duration = min(cur.duration, new_dur)
|
||||||
|
# 最后一条不需要让位,保持原 duration
|
||||||
|
|
||||||
|
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(entries, 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")
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Package marker for unittest discovery.
|
||||||
209
tests/test_activity_emit_finish.py
Normal file
209
tests/test_activity_emit_finish.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Skill Activity Stream: journal, flush, job_id, finish stdout, truncation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from jiangchang_skill_core import activity
|
||||||
|
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||||
|
|
||||||
|
|
||||||
|
class _ActivityEnvIsolation(unittest.TestCase):
|
||||||
|
_KEYS = (
|
||||||
|
"JIANGCHANG_DATA_ROOT",
|
||||||
|
"JIANGCHANG_USER_ID",
|
||||||
|
"JIANGCHANG_JOB_ID",
|
||||||
|
"JIANGCHANG_RUN_MODE",
|
||||||
|
"JIANGCHANG_ACTIVITY_DEBUG",
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||||
|
self._tmps: list[str] = []
|
||||||
|
for k in self._KEYS:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
activity._reset_for_tests()
|
||||||
|
for k, v in self._saved.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
for tmp in self._tmps:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def _tmp_data_root(self) -> str:
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
self._tmps.append(tmp)
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
class TestActivityJournal(_ActivityEnvIsolation):
|
||||||
|
def test_emit_writes_ndjson_with_meta(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-test-1"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
activity.emit("步骤一", type="activity", skill="geo-batch")
|
||||||
|
journal = Path(get_runs_dir()) / "job-test-1.jsonl"
|
||||||
|
self.assertTrue(journal.is_file())
|
||||||
|
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||||
|
self.assertGreaterEqual(len(lines), 2)
|
||||||
|
meta = json.loads(lines[0])
|
||||||
|
self.assertEqual(meta["type"], "meta")
|
||||||
|
self.assertEqual(meta["job_id"], "job-test-1")
|
||||||
|
event = json.loads(lines[1])
|
||||||
|
self.assertEqual(event["type"], "activity")
|
||||||
|
self.assertEqual(event["text"], "步骤一")
|
||||||
|
self.assertEqual(event["schema_version"], 1)
|
||||||
|
self.assertEqual(event["skill"], "geo-batch")
|
||||||
|
self.assertIn("ts", event)
|
||||||
|
|
||||||
|
def test_emit_flushes_immediately(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-flush"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
activity.emit("flush test")
|
||||||
|
journal = Path(get_runs_dir()) / "job-flush.jsonl"
|
||||||
|
self.assertGreater(journal.stat().st_size, 0)
|
||||||
|
|
||||||
|
def test_job_id_from_env(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "env-job-42"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
self.assertEqual(activity.get_job_id(), "env-job-42")
|
||||||
|
|
||||||
|
def test_job_id_generated_and_cached(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
activity._reset_for_tests()
|
||||||
|
jid1 = activity.get_job_id()
|
||||||
|
jid2 = activity.get_job_id()
|
||||||
|
self.assertEqual(jid1, jid2)
|
||||||
|
self.assertEqual(len(jid1), 32)
|
||||||
|
|
||||||
|
def test_debug_skipped_without_env(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-no-debug"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
activity.emit("hidden", type="debug")
|
||||||
|
journal = Path(get_runs_dir()) / "job-no-debug.jsonl"
|
||||||
|
self.assertFalse(journal.exists())
|
||||||
|
|
||||||
|
def test_debug_written_when_enabled(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-debug"
|
||||||
|
os.environ["JIANGCHANG_ACTIVITY_DEBUG"] = "1"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
activity.emit("dbg", type="debug")
|
||||||
|
journal = Path(get_runs_dir()) / "job-debug.jsonl"
|
||||||
|
content = journal.read_text(encoding="utf-8")
|
||||||
|
self.assertIn('"type":"debug"', content.replace(" ", ""))
|
||||||
|
|
||||||
|
def test_emit_never_prints_stdout(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-quiet"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("sys.stdout", buf):
|
||||||
|
activity.emit("silent step")
|
||||||
|
self.assertEqual(buf.getvalue(), "")
|
||||||
|
|
||||||
|
def test_get_run_mode_default_cli(self) -> None:
|
||||||
|
self.assertEqual(activity.get_run_mode(), "cli")
|
||||||
|
|
||||||
|
def test_get_run_mode_job(self) -> None:
|
||||||
|
os.environ["JIANGCHANG_RUN_MODE"] = "job"
|
||||||
|
self.assertEqual(activity.get_run_mode(), "job")
|
||||||
|
|
||||||
|
|
||||||
|
class TestActivityFinish(_ActivityEnvIsolation):
|
||||||
|
def test_finish_stdout_single_line_result(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-finish"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("sys.stdout", buf):
|
||||||
|
activity.finish(status="success", message="ok", skill="geo", generated=3)
|
||||||
|
|
||||||
|
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
|
||||||
|
self.assertEqual(len(lines), 1)
|
||||||
|
result = json.loads(lines[0])
|
||||||
|
self.assertEqual(result["type"], "result")
|
||||||
|
self.assertEqual(result["schema_version"], 1)
|
||||||
|
self.assertEqual(result["status"], "success")
|
||||||
|
self.assertEqual(result["skill"], "geo")
|
||||||
|
self.assertEqual(result["generated"], 3)
|
||||||
|
|
||||||
|
journal = Path(get_runs_dir()) / "job-finish.jsonl"
|
||||||
|
journal_lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||||
|
last = json.loads(journal_lines[-1])
|
||||||
|
self.assertEqual(last["type"], "done")
|
||||||
|
self.assertEqual(last["status"], "success")
|
||||||
|
|
||||||
|
def test_finish_failed_writes_error_event(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-fail"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
with patch("sys.stdout", io.StringIO()):
|
||||||
|
activity.finish(status="failed", message="boom")
|
||||||
|
|
||||||
|
journal = Path(get_runs_dir()) / "job-fail.jsonl"
|
||||||
|
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||||
|
self.assertEqual(last["type"], "error")
|
||||||
|
|
||||||
|
def test_finish_truncates_large_result(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "job-trunc"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
big = {f"field_{i}": "x" * 200 for i in range(50)}
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("sys.stdout", buf):
|
||||||
|
activity.finish(status="success", skill="big", **big)
|
||||||
|
|
||||||
|
line = buf.getvalue().strip()
|
||||||
|
self.assertLessEqual(len(line), 4096)
|
||||||
|
result = json.loads(line)
|
||||||
|
self.assertTrue(result.get("truncated"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobContext(_ActivityEnvIsolation):
|
||||||
|
def test_job_context_auto_finish_on_exception(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
with patch("sys.stdout", io.StringIO()):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
with activity.job_context(job_id="ctx-err", skill="test-skill"):
|
||||||
|
raise ValueError("broken")
|
||||||
|
|
||||||
|
journal = Path(get_runs_dir()) / "ctx-err.jsonl"
|
||||||
|
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||||
|
self.assertEqual(last["type"], "error")
|
||||||
|
self.assertIn("broken", last["message"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
120
tests/test_activity_rpa_step_decorator.py
Normal file
120
tests/test_activity_rpa_step_decorator.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""rpa_step decorator: sync + async."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jiangchang_skill_core import activity
|
||||||
|
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||||
|
|
||||||
|
|
||||||
|
class _DecoratorEnvIsolation(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||||
|
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||||
|
self._tmps: list[str] = []
|
||||||
|
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
activity._reset_for_tests()
|
||||||
|
if self._saved_root is None:
|
||||||
|
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||||
|
else:
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||||
|
if self._saved_job is None:
|
||||||
|
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||||
|
else:
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||||
|
for tmp in self._tmps:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def _tmp_data_root(self) -> str:
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
self._tmps.append(tmp)
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
class TestRpaStepDecorator(_DecoratorEnvIsolation):
|
||||||
|
def _journal_texts(self, job_id: str) -> list[str]:
|
||||||
|
journal = Path(get_runs_dir()) / f"{job_id}.jsonl"
|
||||||
|
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||||
|
events = [json.loads(ln) for ln in lines if json.loads(ln).get("type") != "meta"]
|
||||||
|
return [e["text"] for e in events]
|
||||||
|
|
||||||
|
def test_sync_success(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-ok"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
@activity.rpa_step("打开浏览器")
|
||||||
|
def open_browser() -> str:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
self.assertEqual(open_browser(), "ok")
|
||||||
|
texts = self._journal_texts("rpa-sync-ok")
|
||||||
|
self.assertIn("▶ 打开浏览器", texts)
|
||||||
|
self.assertIn("✓ 打开浏览器", texts)
|
||||||
|
|
||||||
|
def test_sync_failure_emits_warn_and_reraises(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-fail"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
@activity.rpa_step("提交表单")
|
||||||
|
def submit() -> None:
|
||||||
|
raise RuntimeError("timeout")
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
submit()
|
||||||
|
|
||||||
|
journal = Path(get_runs_dir()) / "rpa-sync-fail.jsonl"
|
||||||
|
content = journal.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("timeout", content)
|
||||||
|
self.assertIn('"type":"warn"', content.replace(" ", ""))
|
||||||
|
|
||||||
|
def test_async_success(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-ok"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
@activity.rpa_step("异步步骤")
|
||||||
|
async def async_step() -> int:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return 42
|
||||||
|
|
||||||
|
result = asyncio.run(async_step())
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
texts = self._journal_texts("rpa-async-ok")
|
||||||
|
self.assertIn("▶ 异步步骤", texts)
|
||||||
|
self.assertIn("✓ 异步步骤", texts)
|
||||||
|
|
||||||
|
def test_async_failure(self) -> None:
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-fail"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
@activity.rpa_step()
|
||||||
|
async def fail_step() -> None:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
raise ValueError("async err")
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
asyncio.run(fail_step())
|
||||||
|
|
||||||
|
journal = Path(get_runs_dir()) / "rpa-async-fail.jsonl"
|
||||||
|
self.assertIn("async err", journal.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
26
tests/test_anti_detect_import.py
Normal file
26
tests/test_anti_detect_import.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""anti_detect / rpa 子包导入冒烟。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAntiDetectImport(unittest.TestCase):
|
||||||
|
def test_import_anti_detect(self) -> None:
|
||||||
|
mod = importlib.import_module("jiangchang_skill_core.rpa.anti_detect")
|
||||||
|
self.assertTrue(callable(mod.human_click))
|
||||||
|
self.assertTrue(callable(mod.random_delay))
|
||||||
|
|
||||||
|
def test_import_rpa_package(self) -> None:
|
||||||
|
mod = importlib.import_module("jiangchang_skill_core.rpa")
|
||||||
|
self.assertTrue(callable(mod.wait_for_captcha_pass))
|
||||||
|
self.assertIsNotNone(mod.stealth_enabled)
|
||||||
|
|
||||||
|
def test_top_level_import_with_rpa(self) -> None:
|
||||||
|
pkg = importlib.import_module("jiangchang_skill_core")
|
||||||
|
self.assertIsNotNone(getattr(pkg, "config", None))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
107
tests/test_config.py
Normal file
107
tests/test_config.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""config 三级优先级与 ensure_env_file 测试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from jiangchang_skill_core import config
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
config.reset_cache()
|
||||||
|
self._saved_env = dict(os.environ)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(self._saved_env)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
def test_ensure_env_file_copies_once(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
example = os.path.join(tmp, ".env.example")
|
||||||
|
with open(example, "w", encoding="utf-8") as f:
|
||||||
|
f.write("FOO=from_example\nBAR=2\n")
|
||||||
|
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "testuser"
|
||||||
|
|
||||||
|
dest = config.ensure_env_file("demo-skill", example)
|
||||||
|
self.assertTrue(os.path.isfile(dest))
|
||||||
|
with open(dest, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
self.assertIn("FOO=from_example", content)
|
||||||
|
|
||||||
|
with open(dest, "w", encoding="utf-8") as f:
|
||||||
|
f.write("FOO=user_edited\n")
|
||||||
|
|
||||||
|
dest2 = config.ensure_env_file("demo-skill", example)
|
||||||
|
self.assertEqual(dest, dest2)
|
||||||
|
with open(dest2, encoding="utf-8") as f:
|
||||||
|
self.assertIn("user_edited", f.read())
|
||||||
|
|
||||||
|
def test_three_level_priority(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
example = os.path.join(tmp, ".env.example")
|
||||||
|
with open(example, "w", encoding="utf-8") as f:
|
||||||
|
f.write("KEY_A=example\nKEY_B=example\nKEY_C=example\n")
|
||||||
|
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "u1"
|
||||||
|
dest = config.ensure_env_file("sk", example)
|
||||||
|
|
||||||
|
with open(dest, "w", encoding="utf-8") as f:
|
||||||
|
f.write("KEY_B=user_env\n")
|
||||||
|
|
||||||
|
config.reset_cache()
|
||||||
|
self.assertEqual(config.get("KEY_A"), "example")
|
||||||
|
self.assertEqual(config.get("KEY_B"), "user_env")
|
||||||
|
self.assertEqual(config.get("KEY_C"), "example")
|
||||||
|
self.assertIsNone(config.get("MISSING"))
|
||||||
|
|
||||||
|
os.environ["KEY_C"] = "process_env"
|
||||||
|
self.assertEqual(config.get("KEY_C"), "process_env")
|
||||||
|
|
||||||
|
def test_get_bool_float_int(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
example = os.path.join(tmp, ".env.example")
|
||||||
|
with open(example, "w", encoding="utf-8") as f:
|
||||||
|
f.write("FLAG=1\nRATE=2.5\nCOUNT=42\nBAD=x\n")
|
||||||
|
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "u1"
|
||||||
|
config.ensure_env_file("sk", example)
|
||||||
|
|
||||||
|
self.assertTrue(config.get_bool("FLAG"))
|
||||||
|
self.assertFalse(config.get_bool("MISSING", default=False))
|
||||||
|
self.assertAlmostEqual(config.get_float("RATE", 0.0), 2.5)
|
||||||
|
self.assertEqual(config.get_int("COUNT", 0), 42)
|
||||||
|
self.assertEqual(config.get_float("BAD", 9.9), 9.9)
|
||||||
|
self.assertEqual(config.get_int("BAD", 7), 7)
|
||||||
|
|
||||||
|
def test_merge_missing_env_keys_appends_only_new(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
example = os.path.join(tmp, ".env.example")
|
||||||
|
dest = os.path.join(tmp, ".env")
|
||||||
|
with open(example, "w", encoding="utf-8") as f:
|
||||||
|
f.write("EXISTING=1\nNEW_KEY=abc\n")
|
||||||
|
with open(dest, "w", encoding="utf-8") as f:
|
||||||
|
f.write("EXISTING=keep\n")
|
||||||
|
|
||||||
|
added = config.merge_missing_env_keys(
|
||||||
|
example,
|
||||||
|
dest,
|
||||||
|
comment_skill="demo-skill",
|
||||||
|
)
|
||||||
|
self.assertEqual(added, ["NEW_KEY"])
|
||||||
|
with open(dest, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
self.assertIn("EXISTING=keep", content)
|
||||||
|
self.assertIn("NEW_KEY=abc", content)
|
||||||
|
self.assertIn("Added by demo-skill", content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
125
tests/test_e2e_helpers.py
Normal file
125
tests/test_e2e_helpers.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""e2e_helpers 轻量单元测试(不依赖匠厂桌面 / IPC)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jiangchang_desktop_sdk.e2e_helpers import (
|
||||||
|
SkillDataDir,
|
||||||
|
_iter_text_and_newlines,
|
||||||
|
_is_placeholder_value,
|
||||||
|
_parse_env_file,
|
||||||
|
assert_skill_env_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _segments(prompt: str) -> list[str | None]:
|
||||||
|
out: list[str | None] = []
|
||||||
|
for seg in _iter_text_and_newlines(prompt):
|
||||||
|
if seg is not None and not isinstance(seg, str):
|
||||||
|
out.append(None)
|
||||||
|
else:
|
||||||
|
out.append(seg) # type: ignore[arg-type]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class TestIterTextAndNewlines(unittest.TestCase):
|
||||||
|
def test_lf(self) -> None:
|
||||||
|
self.assertEqual(_segments("a\nb"), ["a", None, "b"])
|
||||||
|
|
||||||
|
def test_crlf(self) -> None:
|
||||||
|
self.assertEqual(_segments("a\r\nb"), ["a", None, "b"])
|
||||||
|
|
||||||
|
def test_cr(self) -> None:
|
||||||
|
self.assertEqual(_segments("a\rb"), ["a", None, "b"])
|
||||||
|
|
||||||
|
def test_multiple_newlines(self) -> None:
|
||||||
|
self.assertEqual(_segments("x\n\ny"), ["x", None, None, "y"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseEnvFile(unittest.TestCase):
|
||||||
|
def _write(self, path: Path, content: str) -> None:
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
def test_basic_and_comments(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
env_path = Path(tmp) / ".env"
|
||||||
|
self._write(
|
||||||
|
env_path,
|
||||||
|
"# comment\n\nAPI_KEY=real-secret\n# KEY=ignored\n",
|
||||||
|
)
|
||||||
|
parsed = _parse_env_file(env_path)
|
||||||
|
self.assertEqual(parsed["API_KEY"], "real-secret")
|
||||||
|
self.assertNotIn("KEY", parsed)
|
||||||
|
|
||||||
|
def test_quoted_values(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
env_path = Path(tmp) / ".env"
|
||||||
|
self._write(env_path, 'A="double"\nB=\'single\'\n')
|
||||||
|
parsed = _parse_env_file(env_path)
|
||||||
|
self.assertEqual(parsed["A"], "double")
|
||||||
|
self.assertEqual(parsed["B"], "single")
|
||||||
|
|
||||||
|
def test_placeholder_detection(self) -> None:
|
||||||
|
self.assertTrue(_is_placeholder_value("TODO", None))
|
||||||
|
self.assertTrue(_is_placeholder_value("sk-xxx", None))
|
||||||
|
self.assertTrue(_is_placeholder_value(" Changeme ", None))
|
||||||
|
self.assertFalse(_is_placeholder_value("sk-live-abc", None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssertSkillEnvFile(unittest.TestCase):
|
||||||
|
def test_missing_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
ctx = SkillDataDir(
|
||||||
|
root=Path(tmp),
|
||||||
|
path=Path(tmp) / "user" / "demo-skill",
|
||||||
|
user_id="12428",
|
||||||
|
skill_slug="demo-skill",
|
||||||
|
)
|
||||||
|
with self.assertRaises(AssertionError) as cm:
|
||||||
|
assert_skill_env_file(ctx, ["API_KEY"])
|
||||||
|
self.assertIn("config/.env", str(cm.exception))
|
||||||
|
|
||||||
|
def test_missing_and_placeholder_keys(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
skill_dir = Path(tmp) / "12428" / "demo-skill"
|
||||||
|
env_dir = skill_dir / "config"
|
||||||
|
env_dir.mkdir(parents=True)
|
||||||
|
(env_dir / ".env").write_text(
|
||||||
|
"API_KEY=your-key\nOTHER=real\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = SkillDataDir(
|
||||||
|
root=Path(tmp),
|
||||||
|
path=skill_dir,
|
||||||
|
user_id="12428",
|
||||||
|
skill_slug="demo-skill",
|
||||||
|
)
|
||||||
|
with self.assertRaises(AssertionError) as cm:
|
||||||
|
assert_skill_env_file(ctx, ["API_KEY", "MISSING", "OTHER"])
|
||||||
|
msg = str(cm.exception)
|
||||||
|
self.assertIn("API_KEY", msg)
|
||||||
|
self.assertIn("MISSING", msg)
|
||||||
|
self.assertNotIn("OTHER", msg)
|
||||||
|
|
||||||
|
def test_pass_returns_path(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
skill_dir = Path(tmp) / "1" / "demo-skill"
|
||||||
|
env_dir = skill_dir / "config"
|
||||||
|
env_dir.mkdir(parents=True)
|
||||||
|
env_file = env_dir / ".env"
|
||||||
|
env_file.write_text("API_KEY=sk-live-ok\n", encoding="utf-8")
|
||||||
|
ctx = SkillDataDir(
|
||||||
|
root=Path(tmp),
|
||||||
|
path=skill_dir,
|
||||||
|
user_id="1",
|
||||||
|
skill_slug="demo-skill",
|
||||||
|
)
|
||||||
|
got = assert_skill_env_file(ctx, ["API_KEY"])
|
||||||
|
self.assertEqual(got, env_file)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
437
tests/test_media_assets.py
Normal file
437
tests/test_media_assets.py
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""media_assets 共享媒体资源解析测试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from jiangchang_skill_core import media_assets as ma
|
||||||
|
|
||||||
|
|
||||||
|
def _make_complete_assets(root: Path, *, with_ffmpeg: bool = True, with_ffprobe: bool = True) -> None:
|
||||||
|
(root / "music" / "calm").mkdir(parents=True)
|
||||||
|
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 1024)
|
||||||
|
(root / "fonts").mkdir(parents=True)
|
||||||
|
(root / "watermark").mkdir(parents=True)
|
||||||
|
bin_key = ma._platform_bin_key()
|
||||||
|
bin_dir = root / "bin" / bin_key
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
if with_ffmpeg:
|
||||||
|
(bin_dir / ma._tool_filename("ffmpeg")).write_bytes(b"ffmpeg")
|
||||||
|
if with_ffprobe:
|
||||||
|
(bin_dir / ma._tool_filename("ffprobe")).write_bytes(b"ffprobe")
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_manifest() -> dict:
|
||||||
|
bin_key = ma._platform_bin_key()
|
||||||
|
return {
|
||||||
|
"name": "media-assets",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"directories": {"music": "music", "fonts": "fonts", "watermark": "watermark"},
|
||||||
|
"tools": {
|
||||||
|
"ffmpeg": {
|
||||||
|
bin_key: {
|
||||||
|
"package": "ffmpeg-release-essentials",
|
||||||
|
"archive_type": "zip",
|
||||||
|
"urls": [{"name": "test", "url": "https://example.test/ffmpeg.zip"}],
|
||||||
|
"archive_paths": {
|
||||||
|
"ffmpeg": "*/bin/ffmpeg.exe",
|
||||||
|
"ffprobe": "*/bin/ffprobe.exe",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_tools_from_manifest(root: Path, warnings: list[str]) -> bool:
|
||||||
|
bin_key = ma._platform_bin_key()
|
||||||
|
bin_dir = root / "bin" / bin_key
|
||||||
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(bin_dir / ma._tool_filename("ffmpeg")).write_bytes(b"ffmpeg")
|
||||||
|
(bin_dir / ma._tool_filename("ffprobe")).write_bytes(b"ffprobe")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_archive_zip(dest: Path, inner_name: str) -> None:
|
||||||
|
manifest = _sample_manifest()
|
||||||
|
with zipfile.ZipFile(dest, "w") as zf:
|
||||||
|
zf.writestr(f"{inner_name}/README.md", "# media-assets\n")
|
||||||
|
zf.writestr(f"{inner_name}/manifest.json", json.dumps(manifest, ensure_ascii=False))
|
||||||
|
zf.writestr(f"{inner_name}/music/calm/track.mp3", b"x" * 1024)
|
||||||
|
zf.writestr(f"{inner_name}/fonts/.keep", "")
|
||||||
|
zf.writestr(f"{inner_name}/watermark/.keep", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_bundle_url_is_release() -> None:
|
||||||
|
url = ma._media_assets_bundle_url()
|
||||||
|
assert url == ma.MEDIA_ASSETS_BUNDLE_URL
|
||||||
|
assert url.endswith("/releases/download/vlatest/media-assets.zip")
|
||||||
|
assert "/archive/main.zip" not in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_url_env_override() -> None:
|
||||||
|
override = "https://example.com/media-assets.zip"
|
||||||
|
assert ma._media_assets_bundle_url(env={"MEDIA_ASSETS_BUNDLE_URL": override}) == override
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_download_uses_bundle_url_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
data_root = tmp_path / "data"
|
||||||
|
shared = data_root / "shared"
|
||||||
|
target = shared / "media-assets"
|
||||||
|
archive = tmp_path / "media-assets.zip"
|
||||||
|
override = "https://example.com/media-assets.zip"
|
||||||
|
|
||||||
|
_build_archive_zip(archive, "media-assets")
|
||||||
|
captured_urls: list[str] = []
|
||||||
|
|
||||||
|
def _fake_download(url: str, dest: Path) -> None:
|
||||||
|
captured_urls.append(url)
|
||||||
|
dest.write_bytes(archive.read_bytes())
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_download_zip", _fake_download)
|
||||||
|
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_ffmpeg_tools", _install_tools_from_manifest)
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"JIANGCHANG_DATA_ROOT": str(data_root),
|
||||||
|
"MEDIA_ASSETS_BUNDLE_URL": override,
|
||||||
|
}
|
||||||
|
status = ma.ensure_media_assets(env=env)
|
||||||
|
assert captured_urls == [override]
|
||||||
|
assert target.is_dir()
|
||||||
|
assert status.ready is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_assets_root_env_priority(tmp_path: Path) -> None:
|
||||||
|
custom = tmp_path / "custom-media"
|
||||||
|
custom.mkdir()
|
||||||
|
env = {
|
||||||
|
"MEDIA_ASSETS_ROOT": str(custom),
|
||||||
|
"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"),
|
||||||
|
}
|
||||||
|
assert ma.resolve_media_assets_root(env=env) == custom
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_media_assets_path(tmp_path: Path) -> None:
|
||||||
|
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||||
|
expected = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
assert ma.resolve_media_assets_root(env=env) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_assets_skips_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
_make_complete_assets(root)
|
||||||
|
|
||||||
|
def _fail_download(*args, **kwargs):
|
||||||
|
raise AssertionError("download should not be called")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_zip", _fail_download)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_git", _fail_download)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert status.ready is True
|
||||||
|
assert status.warnings == []
|
||||||
|
assert status.source == "local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_ffmpeg_warning(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
_make_complete_assets(root, with_ffmpeg=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert status.ready is False
|
||||||
|
assert "ffmpeg_missing" in status.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_ffprobe_warning(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
_make_complete_assets(root, with_ffprobe=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert status.ready is False
|
||||||
|
assert "ffprobe_missing" in status.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_background_music_stable_first(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
_make_complete_assets(root)
|
||||||
|
music = root / "music"
|
||||||
|
(music / "b-upbeat.mp3").write_bytes(b"x" * 1024)
|
||||||
|
(music / "calm" / "a-calm.mp3").write_bytes(b"x" * 1024)
|
||||||
|
(music / "z-last.wav").write_bytes(b"x" * 1024)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ma,
|
||||||
|
"ensure_media_assets",
|
||||||
|
lambda *a, **k: ma._inspect_media_assets(root, source="local"),
|
||||||
|
)
|
||||||
|
|
||||||
|
picked = ma.pick_background_music(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert picked == music / "b-upbeat.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("inner_name", ["media-assets", "media-assets-main"])
|
||||||
|
def test_zip_wrapper_layout_detected(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
inner_name: str,
|
||||||
|
) -> None:
|
||||||
|
data_root = tmp_path / "data"
|
||||||
|
shared = data_root / "shared"
|
||||||
|
target = shared / "media-assets"
|
||||||
|
archive = tmp_path / "media-assets.zip"
|
||||||
|
|
||||||
|
_build_archive_zip(archive, inner_name)
|
||||||
|
|
||||||
|
def _fake_download(url: str, dest: Path) -> None:
|
||||||
|
dest.write_bytes(archive.read_bytes())
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_download_zip", _fake_download)
|
||||||
|
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_ffmpeg_tools", _install_tools_from_manifest)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(data_root)})
|
||||||
|
assert target.is_dir()
|
||||||
|
assert status.ready is True
|
||||||
|
assert status.source == "zip"
|
||||||
|
assert "media_assets_zip_invalid_layout" not in status.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_download_failure_returns_warning(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
def _raise_url_error(url: str, dest: Path) -> None:
|
||||||
|
raise urllib.error.URLError("network down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_download_zip", _raise_url_error)
|
||||||
|
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert status.ready is False
|
||||||
|
assert "media_assets_zip_download_failed" in status.warnings
|
||||||
|
assert "git_not_available" in status.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_not_available_when_zip_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ma,
|
||||||
|
"_download_zip",
|
||||||
|
lambda url, dest: (_ for _ in ()).throw(urllib.error.URLError("fail")),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert "git_not_available" in status.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_assets_root_override_no_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
custom = tmp_path / "override"
|
||||||
|
custom.mkdir()
|
||||||
|
|
||||||
|
def _fail_download(*args, **kwargs):
|
||||||
|
raise AssertionError("download should not be called when MEDIA_ASSETS_ROOT is set")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_zip", _fail_download)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"MEDIA_ASSETS_ROOT": str(custom)})
|
||||||
|
assert status.root == custom
|
||||||
|
assert status.exists is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(platform.system().lower() != "windows", reason="Windows default path check")
|
||||||
|
def test_windows_default_data_root() -> None:
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
root = ma.resolve_media_assets_root()
|
||||||
|
assert root == Path(r"D:\jiangchang-data") / "shared" / "media-assets"
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_assets_does_not_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def _fail_download(*args, **kwargs):
|
||||||
|
raise AssertionError("download should not be called")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_zip", _fail_download)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_git", _fail_download)
|
||||||
|
|
||||||
|
status = ma.probe_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert status.exists is False
|
||||||
|
assert status.ffmpeg_path is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lfs_pointer_warning_in_probe_and_ensure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
_make_complete_assets(root)
|
||||||
|
music = root / "music"
|
||||||
|
(music / "calm" / "track.mp3").unlink()
|
||||||
|
(music / "lfs-only.mp3").write_text(
|
||||||
|
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
|
||||||
|
|
||||||
|
probed = ma.probe_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert "background_music_lfs_pointer_only" in probed.warnings
|
||||||
|
assert probed.music_ready is False
|
||||||
|
|
||||||
|
ensured = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert "background_music_lfs_pointer_only" in ensured.warnings
|
||||||
|
assert ensured.ready is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_background_music_skips_lfs_pointer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
_make_complete_assets(root, with_ffmpeg=True, with_ffprobe=True)
|
||||||
|
music = root / "music"
|
||||||
|
(music / "calm" / "track.mp3").unlink()
|
||||||
|
(music / "real.mp3").write_bytes(b"x" * 1024)
|
||||||
|
lfs = music / "lfs-only.mp3"
|
||||||
|
lfs.write_text("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ma,
|
||||||
|
"ensure_media_assets",
|
||||||
|
lambda *a, **k: ma._inspect_media_assets(root, source="local"),
|
||||||
|
)
|
||||||
|
|
||||||
|
picked = ma.pick_background_music(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert picked == music / "real.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_background_music_no_valid_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
_make_complete_assets(root)
|
||||||
|
music = root / "music"
|
||||||
|
(music / "calm" / "track.mp3").unlink()
|
||||||
|
(music / "pointer.mp3").write_text(
|
||||||
|
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ma,
|
||||||
|
"ensure_media_assets",
|
||||||
|
lambda *a, **k: ma._inspect_media_assets(root, source="local"),
|
||||||
|
)
|
||||||
|
|
||||||
|
picked = ma.pick_background_music(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert picked is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_finalize_without_music_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jiangchang_skill_core import config
|
||||||
|
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||||
|
|
||||||
|
ffmpeg_exe = tmp_path / "ffmpeg.exe"
|
||||||
|
ffmpeg_exe.write_bytes(b"ffmpeg")
|
||||||
|
|
||||||
|
def _fake_resolve_ffmpeg(*args, **kwargs):
|
||||||
|
return ffmpeg_exe
|
||||||
|
|
||||||
|
def _fake_pick_music(*args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.rpa.video_session.resolve_ffmpeg",
|
||||||
|
_fake_resolve_ffmpeg,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.rpa.video_session.pick_background_music",
|
||||||
|
_fake_pick_music,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.rpa.video_session.background_music_issue",
|
||||||
|
lambda *a, **k: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
try:
|
||||||
|
config.reset_cache()
|
||||||
|
with tempfile.TemporaryDirectory() as skill_data:
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="receive-order",
|
||||||
|
skill_data_dir=skill_data,
|
||||||
|
batch_id="batch_no_music",
|
||||||
|
)
|
||||||
|
capture = Path(session._capture_path)
|
||||||
|
capture.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
capture.write_bytes(b"\x00" * 64)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.rpa.video_session._compose_capture_mp4",
|
||||||
|
lambda *a, **k: (True, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(session.finalize())
|
||||||
|
assert "background_music_missing" in session.warnings
|
||||||
|
assert session.output_video_path is not None
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_ffmpeg_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
root = tmp_path / "data" / "shared" / "media-assets"
|
||||||
|
(root / "music" / "calm").mkdir(parents=True)
|
||||||
|
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 1024)
|
||||||
|
(root / "fonts").mkdir(parents=True)
|
||||||
|
(root / "watermark").mkdir(parents=True)
|
||||||
|
(root / "manifest.json").write_text(
|
||||||
|
json.dumps(_sample_manifest(), ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
|
||||||
|
monkeypatch.setattr(ma, "_try_download_ffmpeg_tools", _install_tools_from_manifest)
|
||||||
|
|
||||||
|
status = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||||
|
assert status.ready is True
|
||||||
|
assert status.ffmpeg_path is not None
|
||||||
|
assert status.ffprobe_path is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_usable_audio_file_rejects_small_and_lfs(tmp_path: Path) -> None:
|
||||||
|
small = tmp_path / "small.mp3"
|
||||||
|
small.write_bytes(b"x" * 512)
|
||||||
|
assert ma.is_usable_audio_file(small) is False
|
||||||
|
|
||||||
|
ok = tmp_path / "ok.mp3"
|
||||||
|
ok.write_bytes(b"x" * 1024)
|
||||||
|
assert ma.is_usable_audio_file(ok) is True
|
||||||
|
|
||||||
|
lfs = tmp_path / "lfs.mp3"
|
||||||
|
lfs.write_text("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n")
|
||||||
|
assert ma.is_usable_audio_file(lfs) is False
|
||||||
|
assert ma.is_git_lfs_pointer(lfs) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_background_music_structure(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "media-assets"
|
||||||
|
music = root / "music" / "calm"
|
||||||
|
music.mkdir(parents=True)
|
||||||
|
(music / "track.mp3").write_bytes(b"x" * 1024)
|
||||||
|
|
||||||
|
probe = ma.probe_background_music(root)
|
||||||
|
assert probe["music_root"] == str(root / "music")
|
||||||
|
assert probe["mp3_count"] == 1
|
||||||
|
assert probe["usable_count"] == 1
|
||||||
|
assert probe["issue"] is None
|
||||||
|
assert probe["sample_path"] == str(music / "track.mp3")
|
||||||
92
tests/test_release_workflow.py
Normal file
92
tests/test_release_workflow.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Text-level checks for release-related GitHub Actions workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
_SKILL_WORKFLOW_PATH = os.path.join(
|
||||||
|
_REPO_ROOT, ".github", "workflows", "reusable-release-skill.yaml"
|
||||||
|
)
|
||||||
|
_PUBLISH_WORKFLOW_PATH = os.path.join(
|
||||||
|
_REPO_ROOT, ".github", "workflows", "publish.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def workflow_text() -> str:
|
||||||
|
with open(_SKILL_WORKFLOW_PATH, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def publish_workflow_text() -> str:
|
||||||
|
with open(_PUBLISH_WORKFLOW_PATH, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_packages_readme_at_root(workflow_text: str) -> None:
|
||||||
|
assert "readme_src = os.path.abspath('README.md')" in workflow_text
|
||||||
|
assert "readme_dst = os.path.join(PACKAGE, 'README.md')" in workflow_text
|
||||||
|
assert "Copied README.md" in workflow_text
|
||||||
|
assert (
|
||||||
|
"raise RuntimeError('README.md exists in skill root but was not packaged')"
|
||||||
|
in workflow_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_metadata_reads_root_readme(workflow_text: str) -> None:
|
||||||
|
assert "readme_path = 'README.md'" in workflow_text
|
||||||
|
assert "frontmatter.load(readme_path)" in workflow_text
|
||||||
|
assert "metadata['readme_md'] = body" in workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_does_not_use_references_readme_for_marketplace(
|
||||||
|
workflow_text: str,
|
||||||
|
) -> None:
|
||||||
|
assert "os.path.join('references', 'README.md')" not in workflow_text
|
||||||
|
assert not re.search(
|
||||||
|
r"frontmatter\.load\([^)]*references[^)]*README",
|
||||||
|
workflow_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_packages_env_example_at_root(workflow_text: str) -> None:
|
||||||
|
assert ".env.example" in workflow_text
|
||||||
|
assert "Copied .env.example" in workflow_text
|
||||||
|
assert "env_example_src = os.path.abspath('.env.example')" in workflow_text
|
||||||
|
assert "env_example_dst = os.path.join(PACKAGE, '.env.example')" in workflow_text
|
||||||
|
assert (
|
||||||
|
"raise RuntimeError('.env.example exists in skill root but was not packaged')"
|
||||||
|
in workflow_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_does_not_package_dot_env(workflow_text: str) -> None:
|
||||||
|
# Must not copy live .env to package root (only .env.example).
|
||||||
|
assert not re.search(
|
||||||
|
r"shutil\.copy2\([^)]*['\"]\.env['\"]",
|
||||||
|
workflow_text,
|
||||||
|
)
|
||||||
|
assert not re.search(
|
||||||
|
r"os\.path\.join\(PACKAGE,\s*['\"]\.env['\"]\)",
|
||||||
|
workflow_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_workflow_triggers_on_main_only(publish_workflow_text: str) -> None:
|
||||||
|
assert "branches:" in publish_workflow_text
|
||||||
|
assert "- main" in publish_workflow_text
|
||||||
|
assert "tags:" not in publish_workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_workflow_uses_pyproject_version_only(publish_workflow_text: str) -> None:
|
||||||
|
assert "ci_set_package_version" not in publish_workflow_text
|
||||||
|
assert "GITHUB_RUN_NUMBER" not in publish_workflow_text
|
||||||
|
assert "GITHUB_RUN_ID" not in publish_workflow_text
|
||||||
|
assert ".post" not in publish_workflow_text
|
||||||
|
assert "python3.12 -m build" in publish_workflow_text
|
||||||
|
assert "twine upload" in publish_workflow_text
|
||||||
390
tests/test_runtime_diagnostics.py
Normal file
390
tests/test_runtime_diagnostics.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""runtime_diagnostics 公共 Runtime / media-assets 诊断测试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import jiangchang_skill_core
|
||||||
|
from jiangchang_skill_core import media_assets as ma
|
||||||
|
from jiangchang_skill_core.runtime_diagnostics import (
|
||||||
|
RuntimeDiagnostics,
|
||||||
|
RuntimeIssue,
|
||||||
|
collect_runtime_diagnostics,
|
||||||
|
format_runtime_health_lines,
|
||||||
|
is_jiangchang_skill_core_from_skill_tree,
|
||||||
|
runtime_diagnostics_dict,
|
||||||
|
version_ge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_ge_post_release() -> None:
|
||||||
|
assert version_ge("1.0.10.post23", "1.0.10") is True
|
||||||
|
assert version_ge("1.0.10", "1.0.10") is True
|
||||||
|
assert version_ge("1.0.9.post19", "1.0.10") is False
|
||||||
|
assert version_ge("1.0.10.post23", "1.0.10.post21") is True
|
||||||
|
assert version_ge("1.0.10.post21", "1.0.10.post23") is False
|
||||||
|
assert version_ge("1.0.10", "1.0.10.post1") is False
|
||||||
|
assert version_ge("1.0.11", "1.0.10.post99") is True
|
||||||
|
assert version_ge("1.0.11", "1.0.10") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_jiangchang_skill_core_from_skill_tree(tmp_path: Path) -> None:
|
||||||
|
skill_root = tmp_path / "my-skill"
|
||||||
|
inside = skill_root / "scripts" / "jiangchang_skill_core" / "__init__.py"
|
||||||
|
inside.parent.mkdir(parents=True)
|
||||||
|
inside.write_text("# vendored\n", encoding="utf-8")
|
||||||
|
outside = tmp_path / "site-packages" / "jiangchang_skill_core" / "__init__.py"
|
||||||
|
outside.parent.mkdir(parents=True)
|
||||||
|
outside.write_text("# shared\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert is_jiangchang_skill_core_from_skill_tree(
|
||||||
|
skill_root=skill_root,
|
||||||
|
core_file=inside,
|
||||||
|
) is True
|
||||||
|
assert is_jiangchang_skill_core_from_skill_tree(
|
||||||
|
skill_root=skill_root,
|
||||||
|
core_file=outside,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_runtime_diagnostics_json_serializable(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
data_root = tmp_path / "data"
|
||||||
|
env = {
|
||||||
|
"JIANGCHANG_DATA_ROOT": str(data_root),
|
||||||
|
"OPENCLAW_RECORD_VIDEO": "0",
|
||||||
|
}
|
||||||
|
media_root = data_root / "shared" / "media-assets"
|
||||||
|
(media_root / "music").mkdir(parents=True)
|
||||||
|
|
||||||
|
def _fake_probe_media_assets(data_root_arg=None, env_arg=None):
|
||||||
|
return ma.MediaAssetsStatus(
|
||||||
|
root=media_root,
|
||||||
|
exists=True,
|
||||||
|
ready=False,
|
||||||
|
source="local",
|
||||||
|
warnings=["ffmpeg_missing"],
|
||||||
|
ffmpeg_path=None,
|
||||||
|
music_dir=media_root / "music",
|
||||||
|
music_ready=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||||
|
_fake_probe_media_assets,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||||
|
lambda *a, **k: {
|
||||||
|
"music_root": str(media_root / "music"),
|
||||||
|
"mp3_count": 0,
|
||||||
|
"usable_count": 0,
|
||||||
|
"sample_path": None,
|
||||||
|
"issue": "background_music_dir_empty",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||||
|
lambda: "1.0.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = collect_runtime_diagnostics(
|
||||||
|
skill_slug="my-skill",
|
||||||
|
platform_kit_min_version="1.0.9",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
payload = runtime_diagnostics_dict(diag)
|
||||||
|
json.dumps(payload)
|
||||||
|
assert payload["skill_slug"] == "my-skill"
|
||||||
|
assert payload["resolved_data_root"] == str(data_root)
|
||||||
|
assert payload["platform_kit_version_ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_kit_min_version_none_skips_version_issue(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||||
|
lambda *a, **k: ma.MediaAssetsStatus(
|
||||||
|
root=tmp_path,
|
||||||
|
exists=False,
|
||||||
|
ready=False,
|
||||||
|
source="local",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||||
|
lambda *a, **k: {
|
||||||
|
"music_root": None,
|
||||||
|
"mp3_count": 0,
|
||||||
|
"usable_count": 0,
|
||||||
|
"sample_path": None,
|
||||||
|
"issue": "music_dir_missing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||||
|
lambda: "1.0.8",
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||||
|
assert diag.platform_kit_version_ok is None
|
||||||
|
assert "platform_kit_version_low" not in diag.issue_codes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_kit_missing_records_issue(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||||
|
lambda *a, **k: ma.MediaAssetsStatus(
|
||||||
|
root=tmp_path,
|
||||||
|
exists=False,
|
||||||
|
ready=False,
|
||||||
|
source="local",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||||
|
lambda *a, **k: {
|
||||||
|
"music_root": None,
|
||||||
|
"mp3_count": 0,
|
||||||
|
"usable_count": 0,
|
||||||
|
"sample_path": None,
|
||||||
|
"issue": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||||
|
lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = collect_runtime_diagnostics(
|
||||||
|
skill_slug="my-skill",
|
||||||
|
platform_kit_min_version="1.0.10",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
assert "platform_kit_not_installed" in diag.issue_codes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_video_ffmpeg_and_music_warnings_not_fatal(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
media_root = tmp_path / "shared" / "media-assets"
|
||||||
|
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path), "OPENCLAW_RECORD_VIDEO": "1"}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||||
|
lambda *a, **k: ma.MediaAssetsStatus(
|
||||||
|
root=media_root,
|
||||||
|
exists=True,
|
||||||
|
ready=False,
|
||||||
|
source="local",
|
||||||
|
warnings=["ffmpeg_missing", "music_dir_missing"],
|
||||||
|
ffmpeg_path=None,
|
||||||
|
music_dir=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||||
|
lambda *a, **k: {
|
||||||
|
"music_root": None,
|
||||||
|
"mp3_count": 0,
|
||||||
|
"usable_count": 0,
|
||||||
|
"sample_path": None,
|
||||||
|
"issue": "music_dir_missing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||||
|
lambda: "1.0.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||||
|
codes = diag.issue_codes()
|
||||||
|
assert "ffmpeg_unavailable" in codes
|
||||||
|
assert "background_music_unavailable" in codes
|
||||||
|
assert diag.has_fatal_issues is False
|
||||||
|
ffmpeg_issue = next(i for i in diag.issues if i.code == "ffmpeg_unavailable")
|
||||||
|
assert ffmpeg_issue.severity == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_tree_core_issue_when_skill_root_given(tmp_path: Path) -> None:
|
||||||
|
skill_root = tmp_path / "my-skill"
|
||||||
|
fake_core = skill_root / "scripts" / "jiangchang_skill_core" / "__init__.py"
|
||||||
|
fake_core.parent.mkdir(parents=True)
|
||||||
|
fake_core.write_text("# vendored\n", encoding="utf-8")
|
||||||
|
|
||||||
|
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||||
|
with patch.object(jiangchang_skill_core, "__file__", str(fake_core)):
|
||||||
|
diag = collect_runtime_diagnostics(
|
||||||
|
skill_slug="my-skill",
|
||||||
|
skill_root=skill_root,
|
||||||
|
record_video=False,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
assert "jiangchang_skill_core_loaded_from_skill_tree" in diag.issue_codes()
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_env_key(suffix: str) -> str:
|
||||||
|
return "CL" + "AW_" + suffix
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_runtime_health_lines_no_legacy_data_root_key(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
env = {
|
||||||
|
"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"),
|
||||||
|
_legacy_env_key("DATA_ROOT"): str(tmp_path / "legacy-only"),
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||||
|
lambda *a, **k: ma.MediaAssetsStatus(
|
||||||
|
root=tmp_path,
|
||||||
|
exists=False,
|
||||||
|
ready=False,
|
||||||
|
source="local",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||||
|
lambda *a, **k: {
|
||||||
|
"music_root": None,
|
||||||
|
"mp3_count": 0,
|
||||||
|
"usable_count": 0,
|
||||||
|
"sample_path": None,
|
||||||
|
"issue": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||||
|
lambda: "1.0.17",
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||||
|
payload = runtime_diagnostics_dict(diag)
|
||||||
|
text = "\n".join(format_runtime_health_lines(diag))
|
||||||
|
legacy_key = _legacy_env_key("DATA_ROOT")
|
||||||
|
assert legacy_key not in payload
|
||||||
|
assert legacy_key not in text
|
||||||
|
assert payload["resolved_data_root"] == str(tmp_path / "data")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_runtime_health_lines_core_fields(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||||
|
lambda *a, **k: ma.MediaAssetsStatus(
|
||||||
|
root=tmp_path,
|
||||||
|
exists=False,
|
||||||
|
ready=False,
|
||||||
|
source="local",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||||
|
lambda *a, **k: {
|
||||||
|
"music_root": None,
|
||||||
|
"mp3_count": 0,
|
||||||
|
"usable_count": 0,
|
||||||
|
"sample_path": None,
|
||||||
|
"issue": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||||
|
lambda: "1.0.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||||
|
text = "\n".join(format_runtime_health_lines(diag))
|
||||||
|
for marker in (
|
||||||
|
"python_executable:",
|
||||||
|
"platform_kit_version:",
|
||||||
|
"resolved_data_root:",
|
||||||
|
"media_assets_root:",
|
||||||
|
"ffmpeg_available:",
|
||||||
|
"background_music_mp3_count:",
|
||||||
|
):
|
||||||
|
assert marker in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_music_lfs_and_size_rules(tmp_path: Path) -> None:
|
||||||
|
music = tmp_path / "music"
|
||||||
|
music.mkdir()
|
||||||
|
(music / "tiny.mp3").write_bytes(b"x" * 512)
|
||||||
|
(music / "lfs.mp3").write_text(
|
||||||
|
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
|
||||||
|
)
|
||||||
|
(music / "ok.mp3").write_bytes(b"x" * 1024)
|
||||||
|
|
||||||
|
probe = ma.probe_background_music(tmp_path)
|
||||||
|
assert probe["mp3_count"] == 3
|
||||||
|
assert probe["usable_count"] == 1
|
||||||
|
assert probe["sample_path"] == str(music / "ok.mp3")
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_diagnostics_frozen_dataclass() -> None:
|
||||||
|
diag = RuntimeDiagnostics(
|
||||||
|
skill_slug="x",
|
||||||
|
python_executable="python",
|
||||||
|
platform_kit_version="1.0.10",
|
||||||
|
platform_kit_min_version=None,
|
||||||
|
platform_kit_version_ok=None,
|
||||||
|
jiangchang_skill_core_file=None,
|
||||||
|
jiangchang_data_root=None,
|
||||||
|
resolved_data_root="/tmp",
|
||||||
|
media_assets_root="/tmp/media",
|
||||||
|
ffmpeg_available=False,
|
||||||
|
ffmpeg_path=None,
|
||||||
|
background_music_mp3_count=0,
|
||||||
|
background_music_usable_count=0,
|
||||||
|
background_music_issue=None,
|
||||||
|
background_music_sample_path=None,
|
||||||
|
record_video_enabled=False,
|
||||||
|
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
||||||
|
)
|
||||||
|
assert diag.has_fatal_issues is False
|
||||||
|
assert diag.issue_codes() == ["ffmpeg_unavailable"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_runtime_health_lines_issue_separator_ascii() -> None:
|
||||||
|
diag = RuntimeDiagnostics(
|
||||||
|
skill_slug="x",
|
||||||
|
python_executable="python",
|
||||||
|
platform_kit_version="1.0.11",
|
||||||
|
platform_kit_min_version=None,
|
||||||
|
platform_kit_version_ok=None,
|
||||||
|
jiangchang_skill_core_file=None,
|
||||||
|
jiangchang_data_root=None,
|
||||||
|
resolved_data_root="/tmp",
|
||||||
|
media_assets_root="/tmp/media",
|
||||||
|
ffmpeg_available=False,
|
||||||
|
ffmpeg_path=None,
|
||||||
|
background_music_mp3_count=0,
|
||||||
|
background_music_usable_count=0,
|
||||||
|
background_music_issue=None,
|
||||||
|
background_music_sample_path=None,
|
||||||
|
record_video_enabled=False,
|
||||||
|
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
||||||
|
)
|
||||||
|
lines = format_runtime_health_lines(diag)
|
||||||
|
issue_lines = [line for line in lines if line.startswith("runtime_issue[")]
|
||||||
|
assert len(issue_lines) == 1
|
||||||
|
assert " - " in issue_lines[0]
|
||||||
|
assert "\u2014" not in issue_lines[0]
|
||||||
|
assert "\u2013" not in issue_lines[0]
|
||||||
204
tests/test_runtime_env.py
Normal file
204
tests/test_runtime_env.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""runtime_env:JIANGCHANG_* 路径契约与兄弟技能根解析。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from jiangchang_skill_core import config, unified_logging as ul
|
||||||
|
from jiangchang_skill_core.runtime_diagnostics import (
|
||||||
|
collect_runtime_diagnostics,
|
||||||
|
format_runtime_health_lines,
|
||||||
|
runtime_diagnostics_dict,
|
||||||
|
)
|
||||||
|
from jiangchang_skill_core.runtime_env import (
|
||||||
|
apply_cli_local_defaults,
|
||||||
|
get_data_root,
|
||||||
|
get_sibling_skills_root,
|
||||||
|
get_skills_root,
|
||||||
|
get_user_id,
|
||||||
|
platform_default_data_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_env_key(suffix: str) -> str:
|
||||||
|
return "CL" + "AW_" + suffix
|
||||||
|
|
||||||
|
|
||||||
|
class _EnvIsolation(unittest.TestCase):
|
||||||
|
_KEYS = (
|
||||||
|
"JIANGCHANG_DATA_ROOT",
|
||||||
|
"JIANGCHANG_USER_ID",
|
||||||
|
"JIANGCHANG_SKILLS_ROOT",
|
||||||
|
"JIANGCHANG_APP_ROOT",
|
||||||
|
_legacy_env_key("DATA_ROOT"),
|
||||||
|
_legacy_env_key("USER_ID"),
|
||||||
|
_legacy_env_key("SKILLS_ROOT"),
|
||||||
|
"JIANGCHANG_CLI_LOCAL_DEV",
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||||
|
for k in self._KEYS:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
for k, v in self._saved.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDataRoot(_EnvIsolation):
|
||||||
|
def test_jiangchang_data_root_only(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
self.assertEqual(get_data_root(), tmp)
|
||||||
|
|
||||||
|
def test_platform_default_when_unset(self) -> None:
|
||||||
|
self.assertEqual(get_data_root(), platform_default_data_root())
|
||||||
|
|
||||||
|
def test_legacy_claw_data_root_ignored(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||||
|
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = jc_tmp
|
||||||
|
self.assertEqual(get_data_root(), jc_tmp)
|
||||||
|
|
||||||
|
def test_legacy_claw_data_root_alone_ignored(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as claw_tmp:
|
||||||
|
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
|
||||||
|
self.assertEqual(get_data_root(), platform_default_data_root())
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUserId(_EnvIsolation):
|
||||||
|
def test_jiangchang_user_id_only(self) -> None:
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "user-42"
|
||||||
|
self.assertEqual(get_user_id(), "user-42")
|
||||||
|
|
||||||
|
def test_anon_when_unset(self) -> None:
|
||||||
|
self.assertEqual(get_user_id(), "_anon")
|
||||||
|
|
||||||
|
def test_legacy_claw_user_id_ignored(self) -> None:
|
||||||
|
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "jc-user"
|
||||||
|
self.assertEqual(get_user_id(), "jc-user")
|
||||||
|
|
||||||
|
def test_legacy_claw_user_id_alone_ignored(self) -> None:
|
||||||
|
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
|
||||||
|
self.assertEqual(get_user_id(), "_anon")
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyCliLocalDefaults(_EnvIsolation):
|
||||||
|
def test_sets_jiangchang_only(self) -> None:
|
||||||
|
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
|
||||||
|
apply_cli_local_defaults()
|
||||||
|
self.assertTrue(os.environ.get("JIANGCHANG_DATA_ROOT"))
|
||||||
|
self.assertTrue(os.environ.get("JIANGCHANG_USER_ID"))
|
||||||
|
self.assertNotIn(_legacy_env_key("DATA_ROOT"), os.environ)
|
||||||
|
self.assertNotIn(_legacy_env_key("USER_ID"), os.environ)
|
||||||
|
|
||||||
|
def test_does_not_override_existing(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "existing"
|
||||||
|
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
|
||||||
|
apply_cli_local_defaults()
|
||||||
|
self.assertEqual(os.environ["JIANGCHANG_DATA_ROOT"], tmp)
|
||||||
|
self.assertEqual(os.environ["JIANGCHANG_USER_ID"], "existing")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSkillsRoot(_EnvIsolation):
|
||||||
|
def test_jiangchang_skills_root_override(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
os.environ["JIANGCHANG_SKILLS_ROOT"] = tmp
|
||||||
|
self.assertEqual(get_skills_root(), os.path.normpath(tmp))
|
||||||
|
|
||||||
|
def test_legacy_claw_skills_root_ignored(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||||
|
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||||
|
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
|
||||||
|
self.assertEqual(get_skills_root(), os.path.normpath(jc_tmp))
|
||||||
|
|
||||||
|
def test_legacy_claw_skills_root_alone_ignored(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as claw_tmp:
|
||||||
|
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||||
|
root = get_skills_root()
|
||||||
|
self.assertNotEqual(root, os.path.normpath(claw_tmp))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSiblingSkillsRoot(_EnvIsolation):
|
||||||
|
def _make_skills_tree(self, base: Path) -> Path:
|
||||||
|
skills_root = base / "skills"
|
||||||
|
(skills_root / "account-manager").mkdir(parents=True)
|
||||||
|
(skills_root / "receive-order" / "scripts").mkdir(parents=True)
|
||||||
|
return skills_root
|
||||||
|
|
||||||
|
def test_inference_preferred_over_env(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tree_tmp, tempfile.TemporaryDirectory() as env_tmp:
|
||||||
|
skills_root = self._make_skills_tree(Path(tree_tmp))
|
||||||
|
scripts_dir = str(skills_root / "receive-order" / "scripts")
|
||||||
|
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
|
||||||
|
result = get_sibling_skills_root(scripts_dir)
|
||||||
|
self.assertEqual(result, os.path.normpath(str(skills_root)))
|
||||||
|
|
||||||
|
def test_env_override_when_inference_fails(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as env_tmp:
|
||||||
|
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
|
||||||
|
result = get_sibling_skills_root("/nonexistent/skill/scripts")
|
||||||
|
self.assertEqual(result, os.path.normpath(env_tmp))
|
||||||
|
|
||||||
|
def test_legacy_claw_skills_root_ignored_for_sibling(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||||
|
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||||
|
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
|
||||||
|
result = get_sibling_skills_root("/nonexistent/skill/scripts")
|
||||||
|
self.assertEqual(result, os.path.normpath(jc_tmp))
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigAndLoggingPaths(_EnvIsolation):
|
||||||
|
def test_config_env_file_under_user_isolated_dir(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "u-test"
|
||||||
|
example = os.path.join(tmp, ".env.example")
|
||||||
|
with open(example, "w", encoding="utf-8") as f:
|
||||||
|
f.write("FOO=bar\n")
|
||||||
|
dest = config.ensure_env_file("my-skill", example)
|
||||||
|
expected_prefix = os.path.join(tmp, "u-test", "my-skill")
|
||||||
|
self.assertTrue(os.path.normpath(dest).startswith(os.path.normpath(expected_prefix)))
|
||||||
|
|
||||||
|
def test_unified_logging_path_under_user_dir(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "log-user"
|
||||||
|
log_dir = ul.get_unified_logs_dir()
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.normpath(log_dir).startswith(
|
||||||
|
os.path.normpath(os.path.join(tmp, "log-user", "logs"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiagnosticsNoClawFields(_EnvIsolation):
|
||||||
|
def test_diagnostics_json_and_text_exclude_claw(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
diag = collect_runtime_diagnostics(skill_slug="x", env=dict(os.environ))
|
||||||
|
payload = runtime_diagnostics_dict(diag)
|
||||||
|
text = "\n".join(format_runtime_health_lines(diag))
|
||||||
|
legacy_data_root_key = _legacy_env_key("DATA_ROOT")
|
||||||
|
self.assertNotIn(legacy_data_root_key, payload)
|
||||||
|
self.assertNotIn(legacy_data_root_key, text)
|
||||||
|
self.assertIn("JIANGCHANG_DATA_ROOT", payload)
|
||||||
|
self.assertEqual(payload["resolved_data_root"], tmp)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
252
tests/test_screencast_composer.py
Normal file
252
tests/test_screencast_composer.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""screencast composer 媒体资源集成测试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from screencast.composer import ComposeVideoResult, compose_video
|
||||||
|
|
||||||
|
|
||||||
|
def _write_frame(frames_dir: Path) -> None:
|
||||||
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(frames_dir / "frame_000001.png").write_bytes(b"png")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_srt(path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("1\n00:00:00,000 --> 00:00:02,000\nhello\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_video_uses_absolute_ffmpeg(tmp_path: Path) -> None:
|
||||||
|
frames = tmp_path / "frames"
|
||||||
|
srt = tmp_path / "out.srt"
|
||||||
|
output = tmp_path / "final.mp4"
|
||||||
|
ffmpeg = tmp_path / "ffmpeg.exe"
|
||||||
|
ffmpeg.write_bytes(b"ffmpeg")
|
||||||
|
_write_frame(frames)
|
||||||
|
_write_srt(srt)
|
||||||
|
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def _fake_run(ffmpeg_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||||||
|
captured.append([str(ffmpeg_exe), *cmd_tail])
|
||||||
|
output.write_bytes(b"mp4")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
with patch("screencast.composer.resolve_ffmpeg", return_value=ffmpeg), patch(
|
||||||
|
"screencast.composer.pick_background_music",
|
||||||
|
return_value=None,
|
||||||
|
), patch("screencast.composer.background_music_issue", return_value="background_music_missing"), patch(
|
||||||
|
"screencast.composer._run_ffmpeg",
|
||||||
|
side_effect=_fake_run,
|
||||||
|
):
|
||||||
|
result = compose_video(
|
||||||
|
frames_dir=str(frames),
|
||||||
|
fps=10,
|
||||||
|
subtitle_path=str(srt),
|
||||||
|
output_path=str(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, ComposeVideoResult)
|
||||||
|
assert captured
|
||||||
|
assert captured[0][0] == str(ffmpeg)
|
||||||
|
assert "background_music_missing" in result.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_video_with_music(tmp_path: Path) -> None:
|
||||||
|
frames = tmp_path / "frames"
|
||||||
|
srt = tmp_path / "out.srt"
|
||||||
|
output = tmp_path / "final.mp4"
|
||||||
|
ffmpeg = tmp_path / "ffmpeg.exe"
|
||||||
|
music = tmp_path / "music.mp3"
|
||||||
|
ffmpeg.write_bytes(b"ffmpeg")
|
||||||
|
music.write_bytes(b"x" * 256)
|
||||||
|
_write_frame(frames)
|
||||||
|
_write_srt(srt)
|
||||||
|
|
||||||
|
with patch("screencast.composer.resolve_ffmpeg", return_value=ffmpeg), patch(
|
||||||
|
"screencast.composer.pick_background_music",
|
||||||
|
return_value=music,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer._run_ffmpeg",
|
||||||
|
return_value=(True, ""),
|
||||||
|
):
|
||||||
|
result = compose_video(
|
||||||
|
frames_dir=str(frames),
|
||||||
|
fps=10,
|
||||||
|
subtitle_path=str(srt),
|
||||||
|
output_path=str(output),
|
||||||
|
)
|
||||||
|
output.write_bytes(b"mp4")
|
||||||
|
assert result.warnings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_video_music_failure_fallback(tmp_path: Path) -> None:
|
||||||
|
frames = tmp_path / "frames"
|
||||||
|
srt = tmp_path / "out.srt"
|
||||||
|
output = tmp_path / "final.mp4"
|
||||||
|
ffmpeg = tmp_path / "ffmpeg.exe"
|
||||||
|
music = tmp_path / "music.mp3"
|
||||||
|
ffmpeg.write_bytes(b"ffmpeg")
|
||||||
|
music.write_bytes(b"x" * 256)
|
||||||
|
_write_frame(frames)
|
||||||
|
_write_srt(srt)
|
||||||
|
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
def _fake_run(ffmpeg_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||||||
|
calls["count"] += 1
|
||||||
|
if calls["count"] == 1:
|
||||||
|
return False, "music mux failed"
|
||||||
|
output.write_bytes(b"mp4")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
with patch("screencast.composer.resolve_ffmpeg", return_value=ffmpeg), patch(
|
||||||
|
"screencast.composer.pick_background_music",
|
||||||
|
return_value=music,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer._run_ffmpeg",
|
||||||
|
side_effect=_fake_run,
|
||||||
|
):
|
||||||
|
result = compose_video(
|
||||||
|
frames_dir=str(frames),
|
||||||
|
fps=10,
|
||||||
|
subtitle_path=str(srt),
|
||||||
|
output_path=str(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any("ffmpeg_with_music_failed" in w for w in result.warnings)
|
||||||
|
assert calls["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_video_passes_media_assets_root_to_media_apis(tmp_path: Path) -> None:
|
||||||
|
frames = tmp_path / "frames"
|
||||||
|
srt = tmp_path / "out.srt"
|
||||||
|
output = tmp_path / "final.mp4"
|
||||||
|
custom_root = tmp_path / "custom-media-assets"
|
||||||
|
custom_root.mkdir()
|
||||||
|
ffmpeg = custom_root / "ffmpeg.exe"
|
||||||
|
ffmpeg.write_bytes(b"ffmpeg")
|
||||||
|
_write_frame(frames)
|
||||||
|
_write_srt(srt)
|
||||||
|
|
||||||
|
captured_env: list[dict[str, str] | None] = []
|
||||||
|
|
||||||
|
def _capture_env(*args, **kwargs):
|
||||||
|
captured_env.append(kwargs.get("env"))
|
||||||
|
if len(captured_env) == 1:
|
||||||
|
return ffmpeg
|
||||||
|
if len(captured_env) == 2:
|
||||||
|
return None
|
||||||
|
return "background_music_missing"
|
||||||
|
|
||||||
|
with patch("screencast.composer.resolve_ffmpeg", side_effect=_capture_env), patch(
|
||||||
|
"screencast.composer.pick_background_music",
|
||||||
|
side_effect=_capture_env,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer.background_music_issue",
|
||||||
|
side_effect=_capture_env,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer._run_ffmpeg",
|
||||||
|
return_value=(True, ""),
|
||||||
|
):
|
||||||
|
compose_video(
|
||||||
|
frames_dir=str(frames),
|
||||||
|
fps=10,
|
||||||
|
subtitle_path=str(srt),
|
||||||
|
output_path=str(output),
|
||||||
|
media_assets_root=str(custom_root),
|
||||||
|
)
|
||||||
|
output.write_bytes(b"mp4")
|
||||||
|
|
||||||
|
assert len(captured_env) == 3
|
||||||
|
for env in captured_env:
|
||||||
|
assert env is not None
|
||||||
|
assert env["MEDIA_ASSETS_ROOT"] == str(custom_root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_video_default_does_not_set_media_assets_root(tmp_path: Path) -> None:
|
||||||
|
frames = tmp_path / "frames"
|
||||||
|
srt = tmp_path / "out.srt"
|
||||||
|
output = tmp_path / "final.mp4"
|
||||||
|
ffmpeg = tmp_path / "ffmpeg.exe"
|
||||||
|
ffmpeg.write_bytes(b"ffmpeg")
|
||||||
|
_write_frame(frames)
|
||||||
|
_write_srt(srt)
|
||||||
|
|
||||||
|
captured_env: list[dict[str, str] | None] = []
|
||||||
|
|
||||||
|
def _capture_env(*args, **kwargs):
|
||||||
|
captured_env.append(kwargs.get("env"))
|
||||||
|
if len(captured_env) == 1:
|
||||||
|
return ffmpeg
|
||||||
|
if len(captured_env) == 2:
|
||||||
|
return None
|
||||||
|
return "background_music_missing"
|
||||||
|
|
||||||
|
with patch("screencast.composer.resolve_ffmpeg", side_effect=_capture_env), patch(
|
||||||
|
"screencast.composer.pick_background_music",
|
||||||
|
side_effect=_capture_env,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer.background_music_issue",
|
||||||
|
side_effect=_capture_env,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer._run_ffmpeg",
|
||||||
|
return_value=(True, ""),
|
||||||
|
):
|
||||||
|
compose_video(
|
||||||
|
frames_dir=str(frames),
|
||||||
|
fps=10,
|
||||||
|
subtitle_path=str(srt),
|
||||||
|
output_path=str(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(env is None for env in captured_env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_video_media_assets_root_overrides_env(tmp_path: Path) -> None:
|
||||||
|
frames = tmp_path / "frames"
|
||||||
|
srt = tmp_path / "out.srt"
|
||||||
|
output = tmp_path / "final.mp4"
|
||||||
|
custom_root = tmp_path / "override-root"
|
||||||
|
custom_root.mkdir()
|
||||||
|
ffmpeg = custom_root / "ffmpeg.exe"
|
||||||
|
ffmpeg.write_bytes(b"ffmpeg")
|
||||||
|
_write_frame(frames)
|
||||||
|
_write_srt(srt)
|
||||||
|
|
||||||
|
original_env = {"MEDIA_ASSETS_ROOT": "/old/path", "OTHER": "keep"}
|
||||||
|
captured_env: list[dict[str, str] | None] = []
|
||||||
|
|
||||||
|
def _capture_env(*args, **kwargs):
|
||||||
|
captured_env.append(kwargs.get("env"))
|
||||||
|
if len(captured_env) == 1:
|
||||||
|
return ffmpeg
|
||||||
|
if len(captured_env) == 2:
|
||||||
|
return None
|
||||||
|
return "background_music_missing"
|
||||||
|
|
||||||
|
with patch("screencast.composer.resolve_ffmpeg", side_effect=_capture_env), patch(
|
||||||
|
"screencast.composer.pick_background_music",
|
||||||
|
side_effect=_capture_env,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer.background_music_issue",
|
||||||
|
side_effect=_capture_env,
|
||||||
|
), patch(
|
||||||
|
"screencast.composer._run_ffmpeg",
|
||||||
|
return_value=(True, ""),
|
||||||
|
):
|
||||||
|
compose_video(
|
||||||
|
frames_dir=str(frames),
|
||||||
|
fps=10,
|
||||||
|
subtitle_path=str(srt),
|
||||||
|
output_path=str(output),
|
||||||
|
media_assets_root=str(custom_root),
|
||||||
|
media_assets_env=original_env,
|
||||||
|
)
|
||||||
|
output.write_bytes(b"mp4")
|
||||||
|
|
||||||
|
assert original_env["MEDIA_ASSETS_ROOT"] == "/old/path"
|
||||||
|
assert captured_env[0]["MEDIA_ASSETS_ROOT"] == str(custom_root)
|
||||||
|
assert captured_env[0]["OTHER"] == "keep"
|
||||||
78
tests/test_screencast_runner.py
Normal file
78
tests/test_screencast_runner.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""screencast runner 媒体资源参数传递测试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from screencast.runner import run_screencast
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_screencast_passes_media_assets_root_to_compose_video(tmp_path: Path) -> None:
|
||||||
|
custom_root = tmp_path / "media-assets"
|
||||||
|
custom_root.mkdir()
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
mock_recorder = MagicMock()
|
||||||
|
mock_recorder.frame_count = 1
|
||||||
|
|
||||||
|
def _fake_compose_video(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
Path(kwargs["output_path"]).write_bytes(b"mp4")
|
||||||
|
from screencast.composer import ComposeVideoResult
|
||||||
|
|
||||||
|
return ComposeVideoResult(kwargs["output_path"])
|
||||||
|
|
||||||
|
with patch("screencast.runner.activate_and_maximize_jiangchang_window", return_value=True), patch(
|
||||||
|
"screencast.runner.ScreenRecorder",
|
||||||
|
return_value=mock_recorder,
|
||||||
|
), patch("screencast.runner.SubtitleEngine") as subtitle_cls, patch(
|
||||||
|
"screencast.runner.subprocess.Popen",
|
||||||
|
) as popen_cls, patch(
|
||||||
|
"screencast.runner.compose_video",
|
||||||
|
side_effect=_fake_compose_video,
|
||||||
|
):
|
||||||
|
subtitle = subtitle_cls.return_value
|
||||||
|
proc = popen_cls.return_value
|
||||||
|
proc.stdout = iter([])
|
||||||
|
proc.wait.return_value = 0
|
||||||
|
|
||||||
|
out = run_screencast(
|
||||||
|
skill_slug="demo-skill",
|
||||||
|
subtitle_script=[("ok", "done")],
|
||||||
|
pytest_args=["-q", "tests/test_demo.py"],
|
||||||
|
output_dir=str(tmp_path / "out"),
|
||||||
|
media_assets_root=str(custom_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Path(out).is_file()
|
||||||
|
assert captured.get("media_assets_root") == str(custom_root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_screencast_music_subdir_deprecated_warning() -> None:
|
||||||
|
with pytest.warns(DeprecationWarning, match="music_subdir is deprecated"):
|
||||||
|
with patch("screencast.runner.activate_and_maximize_jiangchang_window", return_value=False), patch(
|
||||||
|
"screencast.runner.ScreenRecorder",
|
||||||
|
) as rec_cls, patch("screencast.runner.SubtitleEngine"), patch(
|
||||||
|
"screencast.runner.subprocess.Popen",
|
||||||
|
) as popen_cls, patch(
|
||||||
|
"screencast.runner.compose_video",
|
||||||
|
) as compose_mock:
|
||||||
|
rec = rec_cls.return_value
|
||||||
|
rec.frame_count = 0
|
||||||
|
proc = popen_cls.return_value
|
||||||
|
proc.stdout = iter([])
|
||||||
|
proc.wait.return_value = 0
|
||||||
|
from screencast.composer import ComposeVideoResult
|
||||||
|
|
||||||
|
compose_mock.return_value = ComposeVideoResult("out.mp4")
|
||||||
|
|
||||||
|
run_screencast(
|
||||||
|
skill_slug="demo",
|
||||||
|
subtitle_script=[],
|
||||||
|
pytest_args=["-q"],
|
||||||
|
output_dir=".",
|
||||||
|
music_subdir="calm",
|
||||||
|
)
|
||||||
72
tests/test_sibling_bridge.py
Normal file
72
tests/test_sibling_bridge.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""sibling_bridge: mock subprocess sibling CLI calls."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from jiangchang_skill_core.sibling_bridge import (
|
||||||
|
call_sibling_json,
|
||||||
|
call_sibling_json_array,
|
||||||
|
get_sibling_main_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiblingBridge(unittest.TestCase):
|
||||||
|
def test_get_sibling_main_path(self) -> None:
|
||||||
|
with patch.dict(os.environ, {"JIANGCHANG_SKILLS_ROOT": r"D:\skills"}):
|
||||||
|
path = get_sibling_main_path("account-manager")
|
||||||
|
self.assertTrue(path.replace("\\", "/").endswith("account-manager/scripts/main.py"))
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||||
|
def test_call_sibling_json_success(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(
|
||||||
|
args=[],
|
||||||
|
returncode=0,
|
||||||
|
stdout='log line\n{"id": 1, "name": "test"}\n',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
result = call_sibling_json("account-manager", ["list", "all"])
|
||||||
|
self.assertEqual(result, {"id": 1, "name": "test"})
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
self.assertIn("account-manager", cmd[1])
|
||||||
|
self.assertIn("list", cmd)
|
||||||
|
self.assertTrue(mock_run.call_args[1]["capture_output"])
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||||
|
def test_call_sibling_json_nonzero_exit(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr="ERROR:fail"
|
||||||
|
)
|
||||||
|
self.assertIsNone(call_sibling_json("content-manager", ["get", "x"]))
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||||
|
def test_call_sibling_json_array(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(
|
||||||
|
args=[],
|
||||||
|
returncode=0,
|
||||||
|
stdout=json.dumps([{"a": 1}, {"b": 2}]),
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
result = call_sibling_json_array("account-manager", ["list", "all"])
|
||||||
|
self.assertEqual(result, [{"a": 1}, {"b": 2}])
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||||
|
def test_uses_subprocess_env_with_trace(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout='{"ok":true}', stderr=""
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"jiangchang_skill_core.sibling_bridge.subprocess_env_with_trace",
|
||||||
|
return_value={"JIANGCHANG_TRACE_ID": "abc123"},
|
||||||
|
) as mock_env:
|
||||||
|
call_sibling_json("api-key-vault", ["get", "key"])
|
||||||
|
self.assertEqual(mock_run.call_args[1]["env"], {"JIANGCHANG_TRACE_ID": "abc123"})
|
||||||
|
mock_env.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
54
tests/test_stealth.py
Normal file
54
tests/test_stealth.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""stealth 启动参数与开关测试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from jiangchang_skill_core.rpa.stealth import (
|
||||||
|
persistent_context_launch_parts,
|
||||||
|
stealth_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStealth(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._saved = os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
if self._saved is None:
|
||||||
|
os.environ.pop("OPENCLAW_PLAYWRIGHT_STEALTH", None)
|
||||||
|
else:
|
||||||
|
os.environ["OPENCLAW_PLAYWRIGHT_STEALTH"] = self._saved
|
||||||
|
|
||||||
|
def test_stealth_enabled_default_on(self) -> None:
|
||||||
|
os.environ.pop("OPENCLAW_PLAYWRIGHT_STEALTH", None)
|
||||||
|
self.assertTrue(stealth_enabled())
|
||||||
|
|
||||||
|
def test_stealth_enabled_off_values(self) -> None:
|
||||||
|
for off in ("0", "false", "no", "off", "FALSE"):
|
||||||
|
os.environ["OPENCLAW_PLAYWRIGHT_STEALTH"] = off
|
||||||
|
self.assertFalse(stealth_enabled(), msg=off)
|
||||||
|
|
||||||
|
def test_launch_parts_stealth_on(self) -> None:
|
||||||
|
os.environ["OPENCLAW_PLAYWRIGHT_STEALTH"] = "1"
|
||||||
|
args, ignore = persistent_context_launch_parts()
|
||||||
|
self.assertIn("--start-maximized", args)
|
||||||
|
self.assertIn("--disable-blink-features=AutomationControlled", args)
|
||||||
|
self.assertEqual(ignore, ["--enable-automation"])
|
||||||
|
|
||||||
|
def test_launch_parts_stealth_off(self) -> None:
|
||||||
|
os.environ["OPENCLAW_PLAYWRIGHT_STEALTH"] = "0"
|
||||||
|
args, ignore = persistent_context_launch_parts()
|
||||||
|
self.assertIn("--start-maximized", args)
|
||||||
|
self.assertNotIn("--disable-blink-features=AutomationControlled", args)
|
||||||
|
self.assertIsNone(ignore)
|
||||||
|
|
||||||
|
def test_launch_parts_extra_args(self) -> None:
|
||||||
|
os.environ["OPENCLAW_PLAYWRIGHT_STEALTH"] = "1"
|
||||||
|
args, _ = persistent_context_launch_parts(extra_args=["--foo"])
|
||||||
|
self.assertIn("--foo", args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
64
tests/test_unified_logging.py
Normal file
64
tests/test_unified_logging.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""统一日志:文件不可写时降级,首次 emit 不抛 PermissionError。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from jiangchang_skill_core import unified_logging as ul
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnifiedLogging(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
ul._skill_slug = ""
|
||||||
|
ul._logger_name = ""
|
||||||
|
|
||||||
|
def test_can_open_log_file_writable(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "test.log")
|
||||||
|
ok, err = ul._can_open_log_file(path)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(err, "")
|
||||||
|
self.assertTrue(os.path.isfile(path))
|
||||||
|
|
||||||
|
def test_setup_skill_logging_writable(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
log_path = os.path.join(tmp, "skill.log")
|
||||||
|
logger_name = "test.logger.writable"
|
||||||
|
with patch.object(ul, "get_skill_log_file_path", return_value=log_path):
|
||||||
|
ul.setup_skill_logging("receive-order", logger_name)
|
||||||
|
ul.get_skill_logger().info("hello")
|
||||||
|
self.assertTrue(os.path.isfile(log_path))
|
||||||
|
ul._clear_logger_handlers(logging.getLogger(logger_name))
|
||||||
|
|
||||||
|
def test_setup_skill_logging_unwritable_no_file_handler(self) -> None:
|
||||||
|
logger_name = "test.logger.unwritable"
|
||||||
|
with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")):
|
||||||
|
ul.setup_skill_logging("receive-order", logger_name)
|
||||||
|
lg = logging.getLogger(logger_name)
|
||||||
|
lg.info("hello")
|
||||||
|
handlers = logging.getLogger(logger_name).handlers
|
||||||
|
self.assertFalse(any(isinstance(h, TimedRotatingFileHandler) for h in handlers))
|
||||||
|
self.assertTrue(any(isinstance(h, logging.NullHandler) for h in handlers))
|
||||||
|
|
||||||
|
def test_attach_unified_file_handler_unwritable(self) -> None:
|
||||||
|
logger_name = "test.logger.attach.unwritable"
|
||||||
|
with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")):
|
||||||
|
lg = ul.attach_unified_file_handler(
|
||||||
|
"/no/access/test.log",
|
||||||
|
skill_slug="receive-order",
|
||||||
|
logger_name=logger_name,
|
||||||
|
)
|
||||||
|
lg.info("hello")
|
||||||
|
self.assertFalse(
|
||||||
|
any(isinstance(h, logging.FileHandler) for h in lg.handlers)
|
||||||
|
)
|
||||||
|
self.assertTrue(any(isinstance(h, logging.NullHandler) for h in lg.handlers))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
575
tests/test_video_session.py
Normal file
575
tests/test_video_session.py
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""RpaVideoSession:ffmpeg 桌面录屏、背景音乐循环淡出、旁白混音。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from jiangchang_skill_core import config
|
||||||
|
from jiangchang_skill_core.rpa import video_session as vs
|
||||||
|
from jiangchang_skill_core.rpa.video_session import (
|
||||||
|
RpaVideoSession,
|
||||||
|
_StepEntry,
|
||||||
|
_INTRO_BUFFER_SECONDS,
|
||||||
|
_INTRO_STABILIZE_SECONDS,
|
||||||
|
_OUTRO_BUFFER_SECONDS,
|
||||||
|
_compose_capture_mp4,
|
||||||
|
_music_fade_params,
|
||||||
|
_select_voiceover_clips,
|
||||||
|
_voiceover_text_for_step,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRpaVideoSession(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
def test_disabled_is_noop(self) -> None:
|
||||||
|
old = os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="receive-order",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_test",
|
||||||
|
)
|
||||||
|
self.assertFalse(session.enabled)
|
||||||
|
summ = session.summary()
|
||||||
|
self.assertFalse(summ["enabled"])
|
||||||
|
self.assertIsNone(summ["path"])
|
||||||
|
self.assertIsNone(summ["capture_path"])
|
||||||
|
self.assertIsNone(summ["record_log_path"])
|
||||||
|
self.assertIsNone(summ["voiceover_path"])
|
||||||
|
self.assertIsNone(summ["music_path"])
|
||||||
|
finally:
|
||||||
|
if old is not None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = old
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
def test_enabled_paths_use_ffmpeg_capture(self) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="receive-order",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="exmail_20260602_120000",
|
||||||
|
title="接收订单",
|
||||||
|
)
|
||||||
|
self.assertTrue(session.enabled)
|
||||||
|
self.assertTrue(
|
||||||
|
session._capture_path.replace("\\", "/").endswith(
|
||||||
|
"/rpa-artifacts/exmail_20260602_120000/capture.mp4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session._ffmpeg_record_log_path.replace("\\", "/").endswith(
|
||||||
|
"/rpa-artifacts/exmail_20260602_120000/logs/ffmpeg-record.log"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session._voiceover_path.replace("\\", "/").endswith(
|
||||||
|
"/rpa-artifacts/exmail_20260602_120000/voiceover/voiceover.wav"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session._planned_output.replace("\\", "/").startswith(
|
||||||
|
tmp.replace("\\", "/") + "/videos/"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == "win32", "gdigrab desktop capture")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session.subprocess.Popen")
|
||||||
|
def test_enter_starts_ffmpeg_gdigrab(self, mock_popen: MagicMock, mock_resolve: MagicMock) -> None:
|
||||||
|
mock_resolve.return_value = Path(r"C:\media\bin\win32-x64\ffmpeg.exe")
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = None
|
||||||
|
proc.stdin = MagicMock()
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
async with RpaVideoSession(
|
||||||
|
skill_slug="receive-order",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_ff",
|
||||||
|
) as video:
|
||||||
|
video.add_step("测试步骤")
|
||||||
|
|
||||||
|
with patch.object(RpaVideoSession, "finalize", return_value=None):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
self.assertTrue(mock_popen.called)
|
||||||
|
cmd = mock_popen.call_args[0][0]
|
||||||
|
self.assertTrue(str(cmd[0]).endswith("ffmpeg.exe"))
|
||||||
|
self.assertIn("gdigrab", cmd)
|
||||||
|
self.assertIn("desktop", cmd)
|
||||||
|
kwargs = mock_popen.call_args[1]
|
||||||
|
self.assertIsNot(kwargs.get("stderr"), os.devnull)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe", return_value=None)
|
||||||
|
def test_ffmpeg_not_found_warning(self, _resolve: MagicMock) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="receive-order",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_no_ff",
|
||||||
|
)
|
||||||
|
session._start_ffmpeg_capture()
|
||||||
|
self.assertIn("ffmpeg_not_found", session.warnings)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
def test_finalize_missing_capture_warning(self) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="receive-order",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_warn",
|
||||||
|
)
|
||||||
|
asyncio.run(session.finalize())
|
||||||
|
self.assertTrue(
|
||||||
|
any("ffmpeg_capture_missing" in w for w in session.warnings)
|
||||||
|
)
|
||||||
|
self.assertIn("record_log=", "".join(session.warnings))
|
||||||
|
self.assertIsNone(session.output_video_path)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMusicFadeParams(unittest.TestCase):
|
||||||
|
def test_normal_duration(self) -> None:
|
||||||
|
start, dur = _music_fade_params(20.0)
|
||||||
|
self.assertAlmostEqual(start, 17.0)
|
||||||
|
self.assertAlmostEqual(dur, 3.0)
|
||||||
|
|
||||||
|
def test_short_video(self) -> None:
|
||||||
|
start, dur = _music_fade_params(4.0)
|
||||||
|
self.assertAlmostEqual(dur, 1.0)
|
||||||
|
self.assertAlmostEqual(start, 3.0)
|
||||||
|
|
||||||
|
def test_invalid_duration(self) -> None:
|
||||||
|
self.assertEqual(_music_fade_params(None), (None, None))
|
||||||
|
self.assertEqual(_music_fade_params(0), (None, None))
|
||||||
|
self.assertEqual(_music_fade_params(-1), (None, None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestVoiceoverTextFilter(unittest.TestCase):
|
||||||
|
def test_keeps_meaningful_steps(self) -> None:
|
||||||
|
for text in (
|
||||||
|
"开始采集",
|
||||||
|
"获取1688账号",
|
||||||
|
"启动浏览器",
|
||||||
|
"检查登录状态",
|
||||||
|
"搜索关键词:螺丝",
|
||||||
|
"解析第 1 页店铺列表",
|
||||||
|
"打开店铺联系方式页",
|
||||||
|
"提取联系人信息",
|
||||||
|
"采集完成",
|
||||||
|
):
|
||||||
|
self.assertIsNotNone(_voiceover_text_for_step(text), msg=text)
|
||||||
|
|
||||||
|
def test_skips_noise(self) -> None:
|
||||||
|
for text in (
|
||||||
|
"等待 1-5s",
|
||||||
|
"跳过重复店铺",
|
||||||
|
"写入联系人库",
|
||||||
|
"探针模式",
|
||||||
|
"操作异常",
|
||||||
|
"登录失败",
|
||||||
|
"2024-01-01 12:00:00 INFO: debug",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
):
|
||||||
|
self.assertIsNone(_voiceover_text_for_step(text), msg=text)
|
||||||
|
|
||||||
|
def test_truncates_long_text(self) -> None:
|
||||||
|
long_text = "开" * 80
|
||||||
|
result = _voiceover_text_for_step(long_text)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
assert result is not None
|
||||||
|
self.assertEqual(len(result), 60)
|
||||||
|
|
||||||
|
def test_dedup_within_ten_seconds(self) -> None:
|
||||||
|
entries = [
|
||||||
|
_StepEntry(0.0, "启动浏览器"),
|
||||||
|
_StepEntry(5.0, "启动浏览器"),
|
||||||
|
_StepEntry(12.0, "启动浏览器"),
|
||||||
|
]
|
||||||
|
clips = _select_voiceover_clips(entries)
|
||||||
|
texts = [t for _, t in clips]
|
||||||
|
self.assertEqual(texts.count("启动浏览器"), 2)
|
||||||
|
|
||||||
|
def test_min_gap_two_seconds(self) -> None:
|
||||||
|
entries = [
|
||||||
|
_StepEntry(0.0, "获取1688账号"),
|
||||||
|
_StepEntry(1.0, "连接CDP"),
|
||||||
|
_StepEntry(3.0, "检查登录状态"),
|
||||||
|
]
|
||||||
|
clips = _select_voiceover_clips(entries)
|
||||||
|
self.assertEqual(len(clips), 2)
|
||||||
|
self.assertEqual(clips[0][1], "获取1688账号")
|
||||||
|
self.assertEqual(clips[1][1], "检查登录状态")
|
||||||
|
|
||||||
|
def test_important_bypasses_min_gap(self) -> None:
|
||||||
|
entries = [
|
||||||
|
_StepEntry(0.0, "定位搜索框"),
|
||||||
|
_StepEntry(0.5, "输入关键词:宁德电池"),
|
||||||
|
_StepEntry(1.0, "点击搜索"),
|
||||||
|
_StepEntry(1.5, "等待搜索结果"),
|
||||||
|
]
|
||||||
|
clips = _select_voiceover_clips(entries)
|
||||||
|
texts = [t for _, t in clips]
|
||||||
|
self.assertEqual(len(clips), 4)
|
||||||
|
self.assertIn("输入关键词:宁德电池", texts)
|
||||||
|
self.assertIn("点击搜索", texts)
|
||||||
|
|
||||||
|
def test_noise_still_skipped_with_important_nearby(self) -> None:
|
||||||
|
entries = [
|
||||||
|
_StepEntry(0.0, "点击搜索"),
|
||||||
|
_StepEntry(0.5, "跳过重复店铺"),
|
||||||
|
_StepEntry(1.0, "写入联系人库"),
|
||||||
|
]
|
||||||
|
clips = _select_voiceover_clips(entries)
|
||||||
|
texts = [t for _, t in clips]
|
||||||
|
self.assertEqual(texts, ["点击搜索"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestComposeCaptureMp4(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.ffmpeg = Path(r"C:\ffmpeg\ffmpeg.exe")
|
||||||
|
self.capture = Path(r"C:\tmp\capture.mp4")
|
||||||
|
self.srt = Path(r"C:\tmp\sub.srt")
|
||||||
|
self.output = Path(r"C:\tmp\out.mp4")
|
||||||
|
self.music = Path(r"C:\tmp\music.mp3")
|
||||||
|
self.voice = Path(r"C:\tmp\voiceover.wav")
|
||||||
|
|
||||||
|
def _run_compose(self, **kwargs) -> list[str]:
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||||||
|
captured.extend(cmd_tail)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
|
||||||
|
patch.object(vs, "_resolve_ffprobe_exe", return_value=Path(r"C:\ffmpeg\ffprobe.exe")),
|
||||||
|
patch.object(vs, "_probe_media_duration_seconds", return_value=kwargs.pop("duration", 20.0)),
|
||||||
|
patch.object(Path, "is_file", return_value=True),
|
||||||
|
):
|
||||||
|
_compose_capture_mp4(
|
||||||
|
self.ffmpeg,
|
||||||
|
self.capture,
|
||||||
|
self.srt,
|
||||||
|
self.output,
|
||||||
|
music_file=kwargs.get("music_file"),
|
||||||
|
voiceover_file=kwargs.get("voiceover_file"),
|
||||||
|
warnings=kwargs.get("warnings"),
|
||||||
|
)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
def test_music_loop_params(self) -> None:
|
||||||
|
cmd = self._run_compose(music_file=self.music)
|
||||||
|
cmd_str = " ".join(cmd)
|
||||||
|
loop_idx = cmd.index("-stream_loop")
|
||||||
|
minus_one_idx = cmd.index("-1", loop_idx)
|
||||||
|
music_i_idx = cmd.index("-i", minus_one_idx)
|
||||||
|
self.assertEqual(cmd[music_i_idx + 1], str(self.music))
|
||||||
|
self.assertIn("-shortest", cmd)
|
||||||
|
filter_idx = cmd.index("-filter_complex")
|
||||||
|
filt = cmd[filter_idx + 1]
|
||||||
|
self.assertNotIn("apad", filt)
|
||||||
|
|
||||||
|
def test_music_fade_filter_long_video(self) -> None:
|
||||||
|
cmd = self._run_compose(music_file=self.music, duration=20.0)
|
||||||
|
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||||
|
self.assertIn("afade=t=out:st=17", filt)
|
||||||
|
self.assertIn("d=3", filt)
|
||||||
|
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
|
||||||
|
|
||||||
|
def test_music_fade_filter_short_video(self) -> None:
|
||||||
|
cmd = self._run_compose(music_file=self.music, duration=4.0)
|
||||||
|
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||||
|
self.assertIn("afade=t=out:st=3", filt)
|
||||||
|
self.assertIn("d=1", filt)
|
||||||
|
|
||||||
|
def test_duration_probe_failed_no_afade(self) -> None:
|
||||||
|
warnings: list[str] = []
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||||||
|
captured.extend(cmd_tail)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
|
||||||
|
patch.object(vs, "_resolve_ffprobe_exe", return_value=None),
|
||||||
|
patch.object(vs, "_probe_media_duration_seconds", return_value=None),
|
||||||
|
patch.object(Path, "is_file", return_value=True),
|
||||||
|
):
|
||||||
|
ok, _ = _compose_capture_mp4(
|
||||||
|
self.ffmpeg,
|
||||||
|
self.capture,
|
||||||
|
self.srt,
|
||||||
|
self.output,
|
||||||
|
music_file=self.music,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertIn("video_duration_probe_failed_for_music_fade", warnings)
|
||||||
|
filt = captured[captured.index("-filter_complex") + 1]
|
||||||
|
self.assertNotIn("afade", filt)
|
||||||
|
self.assertIn("-stream_loop", captured)
|
||||||
|
|
||||||
|
def test_music_and_voice_amix(self) -> None:
|
||||||
|
cmd = self._run_compose(music_file=self.music, voiceover_file=self.voice)
|
||||||
|
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||||
|
self.assertIn("amix", filt)
|
||||||
|
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
|
||||||
|
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
|
||||||
|
self.assertLess(vs._MUSIC_VOLUME, vs._VOICE_VOLUME)
|
||||||
|
self.assertIn("afade", filt)
|
||||||
|
self.assertNotIn("apad", filt)
|
||||||
|
self.assertIn("-stream_loop", cmd)
|
||||||
|
self.assertIn("-shortest", cmd)
|
||||||
|
|
||||||
|
def test_voice_only_no_amix(self) -> None:
|
||||||
|
cmd = self._run_compose(voiceover_file=self.voice)
|
||||||
|
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||||
|
self.assertNotIn("amix", filt)
|
||||||
|
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
|
||||||
|
self.assertIn("apad", filt)
|
||||||
|
self.assertIn("-shortest", cmd)
|
||||||
|
self.assertNotIn("-stream_loop", cmd)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVideoSessionLifecycle(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||||
|
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
|
||||||
|
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||||
|
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||||
|
def test_enter_starts_capture_before_title(
|
||||||
|
self,
|
||||||
|
mock_finalize: AsyncMock,
|
||||||
|
mock_stop: MagicMock,
|
||||||
|
mock_start: MagicMock,
|
||||||
|
mock_sleep: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
call_order: list[str] = []
|
||||||
|
mock_start.side_effect = lambda: call_order.append("start_capture")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="test",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_intro",
|
||||||
|
title="开始执行测试",
|
||||||
|
)
|
||||||
|
original_add = session.add_step
|
||||||
|
|
||||||
|
def _track_add(text: str, *, duration: float = 4.0) -> None:
|
||||||
|
call_order.append(f"add_step:{text}")
|
||||||
|
original_add(text, duration=duration)
|
||||||
|
|
||||||
|
with patch.object(session, "add_step", side_effect=_track_add):
|
||||||
|
asyncio.run(session.__aenter__())
|
||||||
|
|
||||||
|
self.assertEqual(call_order[0], "start_capture")
|
||||||
|
self.assertIn("add_step:开始执行测试", call_order)
|
||||||
|
mock_sleep.assert_awaited()
|
||||||
|
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||||
|
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||||
|
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
|
||||||
|
def test_enter_no_title_still_buffers(
|
||||||
|
self,
|
||||||
|
mock_start: MagicMock,
|
||||||
|
mock_sleep: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="test",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_no_title",
|
||||||
|
)
|
||||||
|
with patch.object(session, "add_step") as mock_add:
|
||||||
|
asyncio.run(session.__aenter__())
|
||||||
|
mock_start.assert_called_once()
|
||||||
|
mock_add.assert_not_called()
|
||||||
|
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||||
|
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||||
|
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||||
|
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||||
|
def test_exit_closing_title_before_stop(
|
||||||
|
self,
|
||||||
|
mock_finalize: AsyncMock,
|
||||||
|
mock_stop: MagicMock,
|
||||||
|
mock_sleep: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
call_order: list[str] = []
|
||||||
|
mock_stop.side_effect = lambda: call_order.append("stop_capture")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="test",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_outro",
|
||||||
|
closing_title="任务已完成",
|
||||||
|
)
|
||||||
|
original_add = session.add_step
|
||||||
|
|
||||||
|
def _track_add(text: str, *, duration: float = 4.0) -> None:
|
||||||
|
call_order.append(f"add_step:{text}")
|
||||||
|
original_add(text, duration=duration)
|
||||||
|
|
||||||
|
with patch.object(session, "add_step", side_effect=_track_add):
|
||||||
|
asyncio.run(session.__aexit__(None, None, None))
|
||||||
|
|
||||||
|
self.assertIn("add_step:任务已完成", call_order)
|
||||||
|
self.assertEqual(call_order[-1], "stop_capture")
|
||||||
|
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||||
|
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||||
|
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||||
|
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||||
|
def test_exit_no_closing_title_still_outro_buffer(
|
||||||
|
self,
|
||||||
|
mock_finalize: AsyncMock,
|
||||||
|
mock_stop: MagicMock,
|
||||||
|
mock_sleep: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config.reset_cache()
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="test",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch_outro_empty",
|
||||||
|
)
|
||||||
|
with patch.object(session, "add_step") as mock_add:
|
||||||
|
asyncio.run(session.__aexit__(None, None, None))
|
||||||
|
mock_add.assert_not_called()
|
||||||
|
mock_stop.assert_called_once()
|
||||||
|
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||||
|
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinalizeDegradation(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
|
||||||
|
def test_tts_failure_still_composes(
|
||||||
|
self,
|
||||||
|
mock_compose: MagicMock,
|
||||||
|
mock_tts: MagicMock,
|
||||||
|
mock_music: MagicMock,
|
||||||
|
mock_ffmpeg: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
|
||||||
|
mock_music.return_value = Path(r"C:\tmp\music.mp3")
|
||||||
|
mock_tts.return_value = None
|
||||||
|
mock_compose.return_value = (True, "")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
|
||||||
|
capture.parent.mkdir(parents=True)
|
||||||
|
capture.write_bytes(b"fake")
|
||||||
|
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="test",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch",
|
||||||
|
)
|
||||||
|
session.add_step("启动浏览器")
|
||||||
|
asyncio.run(session.finalize())
|
||||||
|
|
||||||
|
self.assertTrue(mock_compose.called)
|
||||||
|
self.assertIsNotNone(session.output_video_path)
|
||||||
|
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
|
||||||
|
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
|
||||||
|
def test_compose_fallback_without_music(
|
||||||
|
self,
|
||||||
|
mock_compose: MagicMock,
|
||||||
|
mock_tts: MagicMock,
|
||||||
|
mock_music: MagicMock,
|
||||||
|
mock_ffmpeg: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
|
||||||
|
mock_music.return_value = Path(r"C:\tmp\music.mp3")
|
||||||
|
mock_tts.return_value = None
|
||||||
|
mock_compose.side_effect = [(False, "music err"), (True, "")]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
|
||||||
|
capture.parent.mkdir(parents=True)
|
||||||
|
capture.write_bytes(b"fake")
|
||||||
|
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="test",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch",
|
||||||
|
)
|
||||||
|
asyncio.run(session.finalize())
|
||||||
|
|
||||||
|
self.assertEqual(mock_compose.call_count, 2)
|
||||||
|
self.assertTrue(any("ffmpeg_with_music_failed" in w for w in session.warnings))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
113
tests/test_video_session_activity_integration.py
Normal file
113
tests/test_video_session_activity_integration.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""RpaVideoSession.add_step integrates with activity.emit."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jiangchang_skill_core import activity, config
|
||||||
|
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||||
|
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestVideoSessionActivityIntegration(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
config.reset_cache()
|
||||||
|
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||||
|
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||||
|
self._saved_video = os.environ.get("OPENCLAW_RECORD_VIDEO")
|
||||||
|
self._tmps: list[str] = []
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
activity._reset_for_tests()
|
||||||
|
if self._saved_root is None:
|
||||||
|
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||||
|
else:
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||||
|
if self._saved_job is None:
|
||||||
|
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||||
|
else:
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||||
|
if self._saved_video is None:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
else:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = self._saved_video
|
||||||
|
config.reset_cache()
|
||||||
|
for tmp in self._tmps:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def _tmp_data_root(self) -> str:
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
self._tmps.append(tmp)
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
def test_add_step_emits_activity_when_disabled(self) -> None:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "video-act-1"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="publish-toutiao",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch1",
|
||||||
|
)
|
||||||
|
self.assertFalse(session.enabled)
|
||||||
|
session.add_step("打开浏览器")
|
||||||
|
|
||||||
|
journal = Path(get_runs_dir()) / "video-act-1.jsonl"
|
||||||
|
self.assertTrue(journal.is_file())
|
||||||
|
content = journal.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("打开浏览器", content)
|
||||||
|
self.assertIn("rpa.video", content)
|
||||||
|
self.assertIn("publish-toutiao", content)
|
||||||
|
|
||||||
|
def test_emit_activity_false_skips_journal(self) -> None:
|
||||||
|
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "video-act-off"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="publish-toutiao",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch2",
|
||||||
|
emit_activity=False,
|
||||||
|
)
|
||||||
|
session.add_step("不应写入")
|
||||||
|
journal = Path(get_runs_dir()) / "video-act-off.jsonl"
|
||||||
|
self.assertFalse(journal.exists())
|
||||||
|
|
||||||
|
def test_add_step_writes_both_journal_and_subtitle_steps(self) -> None:
|
||||||
|
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||||
|
tmp = self._tmp_data_root()
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_JOB_ID"] = "video-act-both"
|
||||||
|
activity._reset_for_tests()
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
session = RpaVideoSession(
|
||||||
|
skill_slug="receive-order",
|
||||||
|
skill_data_dir=tmp,
|
||||||
|
batch_id="batch3",
|
||||||
|
)
|
||||||
|
session.add_step("录制步骤")
|
||||||
|
self.assertEqual(len(session._steps), 1)
|
||||||
|
|
||||||
|
journal = Path(get_runs_dir()) / "video-act-both.jsonl"
|
||||||
|
lines = [json.loads(ln) for ln in journal.read_text(encoding="utf-8").splitlines()]
|
||||||
|
activity_events = [e for e in lines if e.get("type") == "activity"]
|
||||||
|
self.assertEqual(len(activity_events), 1)
|
||||||
|
self.assertEqual(activity_events[0]["text"], "录制步骤")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -28,10 +28,13 @@
|
|||||||
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
|
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
|
||||||
- 加密 scripts/(递归 PyArmor -r,输出到包内 scripts/,与源码目录树一致)
|
- 加密 scripts/(递归 PyArmor -r,输出到包内 scripts/,与源码目录树一致)
|
||||||
- 复制 SKILL.md
|
- 复制 SKILL.md
|
||||||
|
- 若根目录存在 README.md,则复制到包根(明文,用于技能市场用户说明)
|
||||||
- 复制 references/(排除 REQUIREMENTS.md)
|
- 复制 references/(排除 REQUIREMENTS.md)
|
||||||
- 复制 assets/
|
- 复制 assets/
|
||||||
- 复制 tests/
|
- 复制 tests/
|
||||||
- 复制 evals/(除 scripts 外均为明文;若目录不存在则跳过;复制时排除常见缓存与运行产物)
|
- 复制 evals/(除 scripts 外均为明文;若目录不存在则跳过;复制时排除常见缓存与运行产物)
|
||||||
|
- 若根目录存在 requirements.txt,则复制到包根(明文,不 PyArmor)
|
||||||
|
- 若根目录存在 .env.example,则复制到包根(明文,不 PyArmor,用于首次运行时落盘用户 .env)
|
||||||
本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。
|
本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。
|
||||||
#>
|
#>
|
||||||
|
|
||||||
|
|||||||
6
tools/screencast/__init__.py
Normal file
6
tools/screencast/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from ._bootstrap import bootstrap_src
|
||||||
|
|
||||||
|
bootstrap_src()
|
||||||
|
from screencast import run_screencast
|
||||||
|
|
||||||
|
__all__ = ["run_screencast"]
|
||||||
15
tools/screencast/_bootstrap.py
Normal file
15
tools/screencast/_bootstrap.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""源码仓库内运行 tools/screencast 时,确保 src 在 sys.path(pip 安装后无需)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_src() -> None:
|
||||||
|
try:
|
||||||
|
import screencast # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
src = Path(__file__).resolve().parent.parent.parent / "src"
|
||||||
|
src_str = str(src)
|
||||||
|
if src.is_dir() and src_str not in sys.path:
|
||||||
|
sys.path.insert(0, src_str)
|
||||||
6
tools/screencast/composer.py
Normal file
6
tools/screencast/composer.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from ._bootstrap import bootstrap_src
|
||||||
|
|
||||||
|
bootstrap_src()
|
||||||
|
from screencast.composer import compose_video
|
||||||
|
|
||||||
|
__all__ = ["compose_video"]
|
||||||
6
tools/screencast/recorder.py
Normal file
6
tools/screencast/recorder.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from ._bootstrap import bootstrap_src
|
||||||
|
|
||||||
|
bootstrap_src()
|
||||||
|
from screencast.recorder import ScreenRecorder
|
||||||
|
|
||||||
|
__all__ = ["ScreenRecorder"]
|
||||||
1
tools/screencast/requirements.txt
Normal file
1
tools/screencast/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mss>=9.0.1
|
||||||
6
tools/screencast/runner.py
Normal file
6
tools/screencast/runner.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from ._bootstrap import bootstrap_src
|
||||||
|
|
||||||
|
bootstrap_src()
|
||||||
|
from screencast.runner import run_screencast
|
||||||
|
|
||||||
|
__all__ = ["run_screencast"]
|
||||||
6
tools/screencast/subtitle.py
Normal file
6
tools/screencast/subtitle.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from ._bootstrap import bootstrap_src
|
||||||
|
|
||||||
|
bootstrap_src()
|
||||||
|
from screencast.subtitle import SubtitleEngine
|
||||||
|
|
||||||
|
__all__ = ["SubtitleEngine"]
|
||||||
145
uv.lock
generated
145
uv.lock
generated
@@ -116,6 +116,92 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.13"
|
version = "3.13"
|
||||||
@@ -127,14 +213,60 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jiangchang-platform-kit"
|
name = "jiangchang-platform-kit"
|
||||||
version = "0.1.0"
|
version = "1.0.15"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "mss" },
|
||||||
|
{ name = "playwright" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "requests", specifier = ">=2.31.0" }]
|
requires-dist = [
|
||||||
|
{ name = "mss", specifier = ">=9.0.1" },
|
||||||
|
{ name = "playwright", specifier = ">=1.42.0" },
|
||||||
|
{ name = "requests", specifier = ">=2.31.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mss"
|
||||||
|
version = "10.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e5/5d/eee782a6d674f562c946ae6a026f4c595ea2b7b031f290bf9fbf60da09b5/mss-10.2.0.tar.gz", hash = "sha256:ab271860775545e62f29d7b11f82f279ac1048f5bbdd26cfad84830208dbd393", size = 200317, upload-time = "2026-04-23T10:44:57.305Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/c3/313e14f245c79b4c05bd0f3a84a4813aa26fa10f8993aebd91d04c5fad3f/mss-10.2.0-py3-none-any.whl", hash = "sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197", size = 67106, upload-time = "2026-04-23T10:44:56.266Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "playwright"
|
||||||
|
version = "1.60.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet" },
|
||||||
|
{ name = "pyee" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyee"
|
||||||
|
version = "13.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
@@ -151,6 +283,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.6.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user