Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 434a405e19 | |||
| aab0207aec | |||
| eba90fb8b9 | |||
| 21d4907f02 | |||
| fb2fa20306 | |||
| 4a9d358aef | |||
| f3fc7b888b | |||
| 84bb085250 | |||
| 8a4a3c8f5d | |||
| 9d4068e4af | |||
| abe02f26cf | |||
| ea25fc2638 | |||
| 43ec2d66a3 | |||
| 311441dab0 | |||
| 6e3f79dde9 | |||
| ffb64b2cfc | |||
| c8d136fb79 | |||
| 34f9a2c141 | |||
| 4fc4cfb802 | |||
| 089f00c3ff | |||
| 20294fd23a | |||
| f52d7fc352 | |||
| cff1408d87 | |||
| f55c7bdc52 | |||
| ebdd8b562d | |||
| d2dbb5f61e | |||
| 8bbf5084f5 | |||
| b9cd4dacec | |||
| d6ad90a7db | |||
| 2174b2b573 | |||
| 71a9ab1700 | |||
| ca117cb5ac | |||
| 892cf837a6 | |||
| a6bbd89350 | |||
| 8778d641ab | |||
| 5037d83b3d | |||
| 8154de452b | |||
| 0bb6707e68 | |||
| 4b15c6d99c | |||
| 4856167682 | |||
| c67487ba16 | |||
| 69702f8ea2 | |||
| 60b4f7a77f | |||
| 7ac6e39ff9 | |||
| 9926eb04cb |
48
.github/workflows/publish.yml
vendored
Normal file
48
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Publish Python Package to Gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PIP_BREAK_SYSTEM_PACKAGES: "1"
|
||||
PATH: /usr/local/bin:/usr/local/python3.12/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin
|
||||
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
PIP_EXTRA_INDEX_URL: https://mirrors.aliyun.com/pypi/simple https://mirrors.cloud.tencent.com/pypi/simple https://mirrors.huaweicloud.com/repository/pypi/simple
|
||||
PIP_DEFAULT_TIMEOUT: "180"
|
||||
PIP_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.huaweicloud.com files.pythonhosted.org pypi.org
|
||||
|
||||
steps:
|
||||
- uses: https://git.jc2009.com/admin/actions-checkout@v4
|
||||
|
||||
- name: Resolve and patch package version
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
GITHUB_RUN_NUMBER: ${{ github.run_number }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: python3.12 tools/ci_set_package_version.py
|
||||
|
||||
- name: Install build tools
|
||||
run: python3.12 -m pip install --upgrade build twine --break-system-packages
|
||||
|
||||
- name: Build distributions
|
||||
run: python3.12 -m build
|
||||
|
||||
- name: Publish to Gitea Package Registry
|
||||
env:
|
||||
TWINE_USERNAME: admin
|
||||
TWINE_PASSWORD: ${{ secrets.PACKAGE_TOKEN }}
|
||||
run: |
|
||||
python3.12 -m twine upload \
|
||||
--repository-url https://git.jc2009.com/api/packages/client-jiangchang/pypi \
|
||||
dist/*
|
||||
115
.github/workflows/reusable-release-frontend.yaml
vendored
Normal file
115
.github/workflows/reusable-release-frontend.yaml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
# Standalone reusable workflow for Node/Vite static sites.
|
||||
# Does not reference reusable-release-skill.yaml; skill workflows remain unchanged.
|
||||
#
|
||||
# Node:请在 Runner 上预装 Node 20+(勿用 actions/setup-node,避免每次从 GitHub 拉包)。
|
||||
# npm:通过 npm_registry 输入使用国内镜像(默认 npmmirror)。
|
||||
# Checkout:不用 actions/checkout(host 模式下 JS Action 仍会进无 Node 的容器);改用 git + github.token。
|
||||
# 重要:act_runner 会把 run: 脚本写到 $GITHUB_WORKSPACE/workflow/*.sh,
|
||||
# 所以 checkout 绝对不能在 workspace 根上 rm -rf ./*,否则会把自己这个脚本一起删了。
|
||||
# 解决办法:克隆到 $GITHUB_WORKSPACE/_src 子目录,后续 step 都在该子目录操作。
|
||||
# 浅拉不要用「裸 SHA」作 fetch 参数(Gitea 上易失败);按分支 clone 再对齐 SHA。
|
||||
|
||||
name: Reusable Frontend Deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
deploy_path:
|
||||
description: "Target directory for static files (trailing slash optional)"
|
||||
required: false
|
||||
type: string
|
||||
default: "/www/wwwroot/sandbox/web"
|
||||
build_command:
|
||||
required: false
|
||||
type: string
|
||||
default: "npm run build:jc2009"
|
||||
npm_registry:
|
||||
description: "npm registry (e.g. npmmirror for CN)"
|
||||
required: false
|
||||
type: string
|
||||
default: "https://registry.npmmirror.com"
|
||||
runs_on:
|
||||
description: "Runner label; must match a registered runner"
|
||||
required: false
|
||||
type: string
|
||||
default: "ubuntu-latest"
|
||||
chown_www:
|
||||
description: "Run chown -R www:www after sync (requires permission on runner)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ${{ inputs.runs_on }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}/_src
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: ${{ inputs.npm_registry }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
# 这一步在 workspace 根执行,不能用默认的 _src(此时还不存在)
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GITEA_HOST: git.jc2009.com
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global --add safe.directory '*'
|
||||
REPO="${{ github.repository }}"
|
||||
SHA="${{ github.sha }}"
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
test -n "$TOKEN" || { echo "Checkout: empty token (github.token not available to this job)"; exit 1; }
|
||||
URL="https://x-access-token:${TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||
|
||||
SRC_DIR="${GITHUB_WORKSPACE}/_src"
|
||||
# 只清理 _src,不动 workspace 根(保护 runner 写入的 workflow/*.sh)
|
||||
rm -rf "$SRC_DIR"
|
||||
git clone --depth 1 --branch "$BRANCH" "$URL" "$SRC_DIR"
|
||||
cd "$SRC_DIR"
|
||||
if [ "$(git rev-parse HEAD)" != "$SHA" ]; then
|
||||
git fetch --depth 200 origin "refs/heads/${BRANCH}"
|
||||
git checkout --force "$SHA"
|
||||
fi
|
||||
echo "Checked out $(git rev-parse HEAD) to $SRC_DIR"
|
||||
|
||||
- name: Check Node.js (pre-installed on runner)
|
||||
run: |
|
||||
set -e
|
||||
command -v node >/dev/null || { echo "Runner 上未找到 node,请先安装 Node 20+"; exit 1; }
|
||||
command -v npm >/dev/null || { echo "Runner 上未找到 npm"; exit 1; }
|
||||
node -v
|
||||
npm -v
|
||||
NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]")
|
||||
test "$NODE_MAJOR" -ge 20 || { echo "需要 Node 20 及以上,当前: $(node -v)"; exit 1; }
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: ${{ inputs.build_command }}
|
||||
|
||||
- name: Deploy (sync dist to web root)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -d dist
|
||||
DEST="${{ inputs.deploy_path }}"
|
||||
DEST="${DEST%/}"
|
||||
mkdir -p "$DEST"
|
||||
# 宝塔面板会自动生成 .user.ini(并加 immutable 属性)和 .htaccess,跳过它们
|
||||
find "$DEST" -mindepth 1 -maxdepth 1 \
|
||||
! -name ".user.ini" \
|
||||
! -name ".htaccess" \
|
||||
-exec rm -rf {} +
|
||||
cp -a dist/. "$DEST/"
|
||||
|
||||
- name: Set ownership for Nginx (Baota www)
|
||||
run: |
|
||||
if [ "${{ inputs.chown_www }}" != "true" ]; then exit 0; fi
|
||||
DEST="${{ inputs.deploy_path }}"
|
||||
DEST="${DEST%/}"
|
||||
chown -R www:www "$DEST" || chown -R nginx:nginx "$DEST" || true
|
||||
110
.github/workflows/reusable-release-skill.yaml
vendored
110
.github/workflows/reusable-release-skill.yaml
vendored
@@ -3,6 +3,11 @@ name: Reusable Skill Release
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
runs_on:
|
||||
description: "Runner label; must match a registered runner (use host runner for pip/python on same machine as Node frontend)"
|
||||
required: false
|
||||
type: string
|
||||
default: ubuntu-latest
|
||||
artifact_platform:
|
||||
required: false
|
||||
type: string
|
||||
@@ -30,23 +35,30 @@ on:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ inputs.runs_on }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
ARTIFACT_PLATFORM: ${{ inputs.artifact_platform }}
|
||||
PYARMOR_PLATFORM: ${{ inputs.pyarmor_platform }}
|
||||
PIP_BREAK_SYSTEM_PACKAGES: "1"
|
||||
# Prefer self-built Python 3.12 under /usr/local (Alibaba Cloud Linux host); keep system paths as fallback.
|
||||
# 显式前缀,避免部分 Runner 未注入 env.PATH 时丢失系统路径
|
||||
PATH: /usr/local/bin:/usr/local/python3.12/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin
|
||||
# PyArmor 交叉平台加密时会内部执行 pip 安装 pyarmor.cli.core.* 等包;不设则默认走 files.pythonhosted.org,国内 CI 易超时。
|
||||
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
PIP_EXTRA_INDEX_URL: https://mirrors.aliyun.com/pypi/simple https://mirrors.cloud.tencent.com/pypi/simple https://mirrors.huaweicloud.com/repository/pypi/simple
|
||||
PIP_DEFAULT_TIMEOUT: "180"
|
||||
PIP_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.huaweicloud.com files.pythonhosted.org pypi.org
|
||||
steps:
|
||||
- uses: http://120.25.191.12:3000/admin/actions-checkout@v4
|
||||
- uses: https://git.jc2009.com/admin/actions-checkout@v4
|
||||
|
||||
# Pin PyArmor 8.5.3 — matches desktop bundles; 9.x trial is stricter in CI。
|
||||
# 镜像由 job env(PIP_INDEX_URL / PIP_EXTRA_INDEX_URL)统一指定,与 Encrypt 步骤中 PyArmor 内部 pip 一致。
|
||||
# 使用 python3.12 -m pip,避免仅存在 python3(3.6) 或裸 pip 不在 PATH 的宿主机/容器。
|
||||
- name: Setup Tools
|
||||
run: pip install "pyarmor==8.5.3" requests python-frontmatter --break-system-packages
|
||||
run: python3.12 -m pip install "pyarmor==8.5.3" requests python-frontmatter --break-system-packages
|
||||
|
||||
- name: Register PyArmor (optional)
|
||||
env:
|
||||
@@ -55,28 +67,100 @@ jobs:
|
||||
if [ -z "${PYARMOR_REG_B64}" ]; then
|
||||
echo "PyArmor: no PYARMOR_REG_B64 secret — trial mode (very large single .py modules may fail to obfuscate)."
|
||||
else
|
||||
python -c "import os,base64,pathlib,subprocess; p=pathlib.Path('/tmp/pyarmor-reg.zip'); p.write_bytes(base64.standard_b64decode(os.environ['PYARMOR_REG_B64'])); subprocess.run(['pyarmor','reg',str(p)],check=True); p.unlink(missing_ok=True)"
|
||||
python3.12 -c "import os,base64,pathlib,subprocess; os.environ['PATH']='/usr/local/bin:/usr/local/python3.12/bin:'+os.environ.get('PATH',''); p=pathlib.Path('/tmp/pyarmor-reg.zip'); p.write_bytes(base64.standard_b64decode(os.environ['PYARMOR_REG_B64'])); subprocess.run(['pyarmor','reg',str(p)],check=True); p.unlink(missing_ok=True)"
|
||||
fi
|
||||
|
||||
# 递归加密整个 scripts/(含 cli、service、db、util 等子包);产物保留与源码一致的 scripts/ 层级,入口为 scripts/main.py。
|
||||
# Obfuscate scripts recursively. Copy package support files as plain text.
|
||||
- name: Encrypt Source Code
|
||||
run: |
|
||||
export PATH="/usr/local/bin:/usr/local/python3.12/bin:${PATH:-}"
|
||||
mkdir -p dist/package/scripts
|
||||
set -euo pipefail
|
||||
test -d scripts
|
||||
( cd scripts && pyarmor gen --platform "${PYARMOR_PLATFORM}" -r -O ../dist/package/scripts . )
|
||||
cp SKILL.md dist/package/
|
||||
if [ -d references ]; then cp -r references dist/package/; fi
|
||||
if [ -d assets ]; then cp -r assets dist/package/; fi
|
||||
|
||||
printf '%s\n' \
|
||||
'import os' \
|
||||
'import shutil' \
|
||||
'' \
|
||||
"PACKAGE = 'dist/package'" \
|
||||
'' \
|
||||
'def common_ignore(dir_path, names):' \
|
||||
' ignored = set()' \
|
||||
' for name in names:' \
|
||||
' lower = name.lower()' \
|
||||
" if name in ('__pycache__', '.pytest_cache', 'artifacts'):" \
|
||||
' ignored.add(name)' \
|
||||
" elif lower.endswith('.pyc') or lower.endswith('.pyo') or lower.endswith('.db'):" \
|
||||
' ignored.add(name)' \
|
||||
' return list(ignored)' \
|
||||
'' \
|
||||
"ref_src = os.path.abspath('references')" \
|
||||
'' \
|
||||
'def references_ignore(dir_path, names):' \
|
||||
' ignored = set(common_ignore(dir_path, names))' \
|
||||
" if os.path.abspath(dir_path) == ref_src and 'REQUIREMENTS.md' in names:" \
|
||||
" ignored.add('REQUIREMENTS.md')" \
|
||||
' return list(ignored)' \
|
||||
'' \
|
||||
'def copy_dir(src, dst, ignore_fn):' \
|
||||
' if os.path.isdir(src):' \
|
||||
' shutil.copytree(src, dst, ignore=ignore_fn, dirs_exist_ok=True)' \
|
||||
'' \
|
||||
"shutil.copy2('SKILL.md', os.path.join(PACKAGE, 'SKILL.md'))" \
|
||||
"copy_dir('references', os.path.join(PACKAGE, 'references'), references_ignore)" \
|
||||
"copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \
|
||||
"copy_dir('tests', os.path.join(PACKAGE, 'tests'), 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:')" \
|
||||
'for name in sorted(os.listdir(PACKAGE)):' \
|
||||
" print(' -', name)" \
|
||||
> /tmp/package_skill_release_copy.py
|
||||
|
||||
python3.12 /tmp/package_skill_release_copy.py
|
||||
|
||||
- name: Parse Metadata and Pack
|
||||
id: build_task
|
||||
run: |
|
||||
python -c "
|
||||
python3.12 -c "
|
||||
import frontmatter, os, json, shutil
|
||||
post = frontmatter.load('SKILL.md')
|
||||
metadata = dict(post.metadata or {})
|
||||
metadata['readme_md'] = (post.content or '').strip()
|
||||
skill_readme_md = (post.content or '').strip()
|
||||
skill_description = metadata.get('description')
|
||||
metadata['readme_md'] = skill_readme_md
|
||||
readme_path = os.path.join('references', 'README.md')
|
||||
if os.path.isfile(readme_path):
|
||||
try:
|
||||
readme_post = frontmatter.load(readme_path)
|
||||
body = (readme_post.content or '').strip()
|
||||
rm_meta = readme_post.metadata or {}
|
||||
desc = rm_meta.get('description')
|
||||
if desc is not None and str(desc).strip():
|
||||
metadata['description'] = str(desc).strip()
|
||||
if body:
|
||||
metadata['readme_md'] = body
|
||||
except Exception:
|
||||
pass
|
||||
if not (metadata.get('readme_md') or '').strip():
|
||||
metadata['readme_md'] = skill_readme_md
|
||||
if skill_description is not None and not str(metadata.get('description', '') or '').strip():
|
||||
metadata['description'] = skill_description
|
||||
openclaw_meta = metadata.get('metadata', {}).get('openclaw', {})
|
||||
slug = (openclaw_meta.get('slug') or metadata.get('slug') or metadata.get('name') or '').strip()
|
||||
if not slug:
|
||||
@@ -102,7 +186,7 @@ jobs:
|
||||
METADATA_JSON: ${{ steps.build_task.outputs.metadata }}
|
||||
SYNC_URL: ${{ inputs.sync_url }}
|
||||
run: |
|
||||
python -c "
|
||||
python3.12 -c "
|
||||
import requests, json, os
|
||||
metadata = json.loads(os.environ['METADATA_JSON'])
|
||||
res = requests.post(os.environ['SYNC_URL'], json=metadata)
|
||||
@@ -121,7 +205,7 @@ jobs:
|
||||
ARTIFACT_PLATFORM: ${{ steps.build_task.outputs.artifact_platform }}
|
||||
UPLOAD_URL: ${{ inputs.upload_url }}
|
||||
run: |
|
||||
python -c "
|
||||
python3.12 -c "
|
||||
import requests, os
|
||||
slug = os.environ['SLUG']
|
||||
version = os.environ['VERSION']
|
||||
@@ -148,7 +232,7 @@ jobs:
|
||||
VERSION: ${{ steps.build_task.outputs.version }}
|
||||
PRUNE_URL: ${{ inputs.prune_url }}
|
||||
run: |
|
||||
python -c "
|
||||
python3.12 -c "
|
||||
import requests, os
|
||||
payload = {
|
||||
'name': os.environ['SLUG'],
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
**/*.egg-info/
|
||||
.env
|
||||
.python-version
|
||||
build/
|
||||
dist/
|
||||
python-runtime/.venv/
|
||||
|
||||
211
README.md
211
README.md
@@ -1,7 +1,210 @@
|
||||
# jiangchang-platform-kit
|
||||
|
||||
Shared platform components for Jiangchang skills:
|
||||
## 1. 项目简介
|
||||
|
||||
- `sdk/jiangchang_skill_core`: entitlement SDK package.
|
||||
- `.github/workflows/reusable-release-skill.yaml`: reusable CI release workflow.
|
||||
- `examples/workflows/use-reusable-release-skill.yaml`: caller workflow sample.
|
||||
匠厂平台共享 Python SDK:在同一发行包中提供 **Skill 侧通用能力**(权益校验、统一日志与运行环境解析)与 **桌面应用自动化能力**(通过 CDP 连接匠厂客户端,驱动聊天与断言)。
|
||||
|
||||
## 2. 安装方式
|
||||
|
||||
```bash
|
||||
pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/
|
||||
```
|
||||
|
||||
## 3. 包含的模块
|
||||
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
### jiangchang_skill_core
|
||||
|
||||
```python
|
||||
import os
|
||||
from jiangchang_skill_core import (
|
||||
EntitlementClient,
|
||||
enforce_entitlement,
|
||||
setup_skill_logging,
|
||||
get_skill_logger,
|
||||
)
|
||||
|
||||
os.environ.setdefault("JIANGCHANG_AUTH_BASE_URL", "https://auth.example.com")
|
||||
os.environ.setdefault("JIANGCHANG_AUTH_API_KEY", "secret")
|
||||
|
||||
setup_skill_logging(skill_slug="demo-skill", logger_name="demo")
|
||||
log = get_skill_logger()
|
||||
log.info("skill started")
|
||||
|
||||
# client = EntitlementClient()
|
||||
# enforce_entitlement(user_id="u1", skill_slug="demo-skill")
|
||||
```
|
||||
|
||||
### jiangchang_desktop_sdk
|
||||
|
||||
```python
|
||||
from jiangchang_desktop_sdk import JiangchangDesktopClient, AskOptions
|
||||
|
||||
# 需已安装 playwright,且匠厂客户端可按 CDP 端口暴露调试接口
|
||||
client = JiangchangDesktopClient()
|
||||
client.ensure_app_running(wait_timeout_s=30.0)
|
||||
try:
|
||||
answer = client.ask("你好", options=AskOptions(new_task=True))
|
||||
print(answer)
|
||||
finally:
|
||||
client.disconnect()
|
||||
```
|
||||
|
||||
### 桌面 E2E(`jiangchang_desktop_sdk.e2e_helpers`)
|
||||
|
||||
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` / `CLAW_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
|
||||
|
||||
推荐 preflight 流程:
|
||||
|
||||
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第二行")
|
||||
```
|
||||
|
||||
## 共享媒体资源
|
||||
|
||||
`jiangchang_skill_core.media_assets` 提供共享媒体资源解析能力。默认会从:
|
||||
|
||||
`{JIANGCHANG_DATA_ROOT}/shared/media-assets`
|
||||
|
||||
读取背景音乐、字体、水印和 ffmpeg 工具。
|
||||
|
||||
如果目录不存在,会优先通过 ZIP 下载公开仓库:
|
||||
|
||||
`https://git.jc2009.com/client-commons/media-assets/archive/main.zip`
|
||||
|
||||
这不依赖用户电脑安装 Git。
|
||||
|
||||
可通过环境变量覆盖:
|
||||
|
||||
`MEDIA_ASSETS_ROOT=/path/to/custom/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. 发版流程(维护者)
|
||||
|
||||
### main 分支自动预发布
|
||||
|
||||
每次推送到 `main` 会触发 `.github/workflows/publish.yml`,自动构建并发布 **post 预发布版本**(仅供 CI 流水线内部使用,**不是**正式版本线)。
|
||||
|
||||
正式版本线使用 **1.0.x** semver,通过 git tag 发布。
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
测试人员升级安装(始终安装当前正式 tag 对应的版本,例如 `1.0.9`):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 正式版本(tag)
|
||||
|
||||
打 tag 并推送后,发布 **去掉 `v` 前缀** 的正式版本号(例如 `v1.0.9` → PyPI 包 `1.0.9`):
|
||||
|
||||
```bash
|
||||
git tag v1.0.9
|
||||
git push origin v1.0.9
|
||||
```
|
||||
|
||||
### 安装后验证
|
||||
|
||||
```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(...)` 按预期抛错。
|
||||
@@ -5,8 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@v0.1.0
|
||||
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@v0.1.0
|
||||
with:
|
||||
artifact_platform: windows
|
||||
pyarmor_platform: windows.x86_64
|
||||
include_readme_md: false
|
||||
|
||||
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "1.0.10"
|
||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "MIT"
|
||||
authors = [{ name = "client-jiangchang" }]
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
"playwright>=1.42.0",
|
||||
"mss>=9.0.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
12
python-runtime/README.md
Normal file
12
python-runtime/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 匠厂共享 Python 运行时目录(JIANGCHANG)
|
||||
|
||||
本目录保留为技能运行时安装的**目标路径约定**:宿主可将第三方依赖集合同步到用户机器的
|
||||
|
||||
`{JIANGCHANG_DATA_ROOT}/python-runtime/`
|
||||
|
||||
并在该目录维护虚拟环境(例如使用 `uv sync` 或 `pip install -r requirements.txt`)。
|
||||
|
||||
**说明**:本仓库已改为在根目录通过 `pyproject.toml` 发布 **`jiangchang-platform-kit`** 单一 Python 包;原先在此处单独声明的 `pyproject.toml` / `uv.lock` 已移除。
|
||||
Playwright、OpenAI SDK 等与具体技能相关的依赖,请由各技能或宿主 **`resources/jiangchang-python-runtime/`**(桌面应用内嵌模板)继续维护;与本仓库中的 SDK 包版本号无关。
|
||||
|
||||
**Playwright**:仅安装 Python 包 `playwright` 即可;技能可通过 **`channel=chrome` / `msedge`** 使用用户本机已安装的 Chrome 或 Edge。宿主流程一般不必执行 `playwright install chromium`(除非你希望使用自带 Chromium)。
|
||||
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)
|
||||
@@ -1,39 +0,0 @@
|
||||
from .client import EntitlementClient
|
||||
from .guard import enforce_entitlement
|
||||
from .models import EntitlementResult
|
||||
from .runtime_env import (
|
||||
apply_cli_local_defaults,
|
||||
get_data_root,
|
||||
get_sibling_skills_root,
|
||||
get_skills_root,
|
||||
get_user_id,
|
||||
platform_default_data_root,
|
||||
)
|
||||
from .unified_logging import (
|
||||
attach_unified_file_handler,
|
||||
ensure_trace_for_process,
|
||||
get_skill_log_file_path,
|
||||
get_skill_logger,
|
||||
get_unified_logs_dir,
|
||||
setup_skill_logging,
|
||||
subprocess_env_with_trace,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EntitlementClient",
|
||||
"EntitlementResult",
|
||||
"apply_cli_local_defaults",
|
||||
"attach_unified_file_handler",
|
||||
"enforce_entitlement",
|
||||
"ensure_trace_for_process",
|
||||
"get_data_root",
|
||||
"get_sibling_skills_root",
|
||||
"get_skills_root",
|
||||
"get_skill_log_file_path",
|
||||
"get_skill_logger",
|
||||
"get_unified_logs_dir",
|
||||
"get_user_id",
|
||||
"platform_default_data_root",
|
||||
"setup_skill_logging",
|
||||
"subprocess_env_with_trace",
|
||||
]
|
||||
@@ -1,127 +0,0 @@
|
||||
"""
|
||||
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
|
||||
|
||||
各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启
|
||||
CLI_LOCAL_DEV_ENABLED = True
|
||||
|
||||
# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改)
|
||||
DEFAULT_LOCAL_USER_ID = "10032"
|
||||
|
||||
# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径
|
||||
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
|
||||
|
||||
# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列)
|
||||
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
|
||||
|
||||
|
||||
def platform_default_data_root() -> str:
|
||||
if sys.platform == "win32":
|
||||
return WIN_DEFAULT_DATA_ROOT
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
if not path or not os.path.isdir(path):
|
||||
return False
|
||||
for marker in (
|
||||
"llm-manager",
|
||||
"content-manager",
|
||||
"account-manager",
|
||||
"sohu-publisher",
|
||||
"api-key-vault",
|
||||
):
|
||||
if os.path.isdir(os.path.join(path, marker)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_skills_root() -> str:
|
||||
"""
|
||||
并列技能安装目录(其下为「技能 slug」子目录)。
|
||||
|
||||
优先级:
|
||||
1) JIANGCHANG_SKILLS_ROOT
|
||||
2) CLAW_SKILLS_ROOT
|
||||
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||
5) 其他平台:~/.openclaw/skills
|
||||
"""
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||
if sys.platform == "win32" and not app:
|
||||
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
|
||||
if app:
|
||||
nested = os.path.join(app, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(app):
|
||||
return os.path.normpath(app)
|
||||
|
||||
if sys.platform == "win32":
|
||||
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
|
||||
return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT)
|
||||
|
||||
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
|
||||
|
||||
|
||||
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
"""
|
||||
编排子进程调用兄弟技能时用:优先环境变量;否则从本技能 scripts 目录推断并列根
|
||||
(OpenClaw 开发仓与网关 skills/<slug>/scripts 均满足「技能根之上一级 = 并列根」)。
|
||||
"""
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
if skill_scripts_dir:
|
||||
scripts = os.path.abspath(skill_scripts_dir)
|
||||
skill_root = os.path.dirname(scripts)
|
||||
inferred = os.path.dirname(skill_root)
|
||||
if _looks_like_skills_root(inferred):
|
||||
return inferred
|
||||
|
||||
return get_skills_root()
|
||||
|
||||
|
||||
def apply_cli_local_defaults() -> None:
|
||||
"""
|
||||
在 CLI 最早阶段调用(main.py 在 import 业务包之前)。
|
||||
宿主已设置 JIANGCHANG_* 时不会覆盖。
|
||||
"""
|
||||
enabled = CLI_LOCAL_DEV_ENABLED
|
||||
if not enabled:
|
||||
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||
@@ -1,19 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jiangchang-skill-core"
|
||||
version = "0.1.0"
|
||||
description = "Common entitlement SDK for Jiangchang skills"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "."}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["jiangchang_skill_core*"]
|
||||
38
src/jiangchang_desktop_sdk/__init__.py
Normal file
38
src/jiangchang_desktop_sdk/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from .exceptions import (
|
||||
AppNotFoundError,
|
||||
AssertError,
|
||||
ConnectionError,
|
||||
GatewayDownError,
|
||||
JiangchangDesktopError,
|
||||
LaunchError,
|
||||
TimeoutError,
|
||||
)
|
||||
from .types import AskOptions, AssertOptions, JiangchangMessage, LaunchOptions
|
||||
|
||||
__all__ = [
|
||||
"JiangchangDesktopClient",
|
||||
"JiangchangMessage",
|
||||
"AskOptions",
|
||||
"LaunchOptions",
|
||||
"AssertOptions",
|
||||
"JiangchangDesktopError",
|
||||
"AppNotFoundError",
|
||||
"ConnectionError",
|
||||
"TimeoutError",
|
||||
"AssertError",
|
||||
"LaunchError",
|
||||
"GatewayDownError",
|
||||
]
|
||||
__version__ = "1.0.9"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "JiangchangDesktopClient":
|
||||
from .client import JiangchangDesktopClient
|
||||
|
||||
return JiangchangDesktopClient
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted({*__all__, "__version__"})
|
||||
1070
src/jiangchang_desktop_sdk/client.py
Normal file
1070
src/jiangchang_desktop_sdk/client.py
Normal file
File diff suppressed because it is too large
Load Diff
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})"
|
||||
)
|
||||
27
src/jiangchang_desktop_sdk/exceptions.py
Normal file
27
src/jiangchang_desktop_sdk/exceptions.py
Normal file
@@ -0,0 +1,27 @@
|
||||
class JiangchangDesktopError(Exception):
|
||||
"""基类"""
|
||||
pass
|
||||
|
||||
class AppNotFoundError(JiangchangDesktopError):
|
||||
"""Electron 可执行文件找不到时抛出"""
|
||||
pass
|
||||
|
||||
class ConnectionError(JiangchangDesktopError, ConnectionError):
|
||||
"""连接桌面应用失败时抛出"""
|
||||
pass
|
||||
|
||||
class TimeoutError(JiangchangDesktopError, TimeoutError):
|
||||
"""操作超时"""
|
||||
pass
|
||||
|
||||
class AssertError(JiangchangDesktopError):
|
||||
"""断言失败时抛出,消息要包含期望值和实际值"""
|
||||
pass
|
||||
|
||||
class LaunchError(JiangchangDesktopError):
|
||||
"""应用启动失败时抛出"""
|
||||
pass
|
||||
|
||||
class GatewayDownError(JiangchangDesktopError):
|
||||
"""Gateway 在等待过程中被检测到已停止/退出时抛出,便于测试立刻失败而不是空等超时。"""
|
||||
pass
|
||||
24
src/jiangchang_desktop_sdk/testing/__init__.py
Normal file
24
src/jiangchang_desktop_sdk/testing/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""匠厂桌面 E2E 与集成测试常用工具子包。
|
||||
|
||||
提供:
|
||||
- ``SkillInfo`` / ``discover_skill_root`` / ``parse_skill_md``
|
||||
- ``skill_healthcheck``
|
||||
- ``HostAPIClient`` / ``HostAPIError``
|
||||
|
||||
**不提供任何会话清理能力**:测试完全模拟真实用户的 UI 操作,
|
||||
真实用户从不会去后台删会话,测试也不该走底层 API / 文件系统去清。
|
||||
"""
|
||||
from .config import SkillInfo, discover_skill_root, parse_skill_md
|
||||
from .healthcheck import HealthCheckError, skill_healthcheck
|
||||
from .host_api import HostAPIClient, HostAPIError
|
||||
|
||||
__all__ = [
|
||||
"SkillInfo",
|
||||
"discover_skill_root",
|
||||
"parse_skill_md",
|
||||
"HealthCheckError",
|
||||
"skill_healthcheck",
|
||||
"HostAPIClient",
|
||||
"HostAPIError",
|
||||
]
|
||||
137
src/jiangchang_desktop_sdk/testing/config.py
Normal file
137
src/jiangchang_desktop_sdk/testing/config.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Skill 元信息读取。
|
||||
|
||||
负责:
|
||||
- 定位 skill 根目录(含 SKILL.md);
|
||||
- 解析 SKILL.md 的 YAML frontmatter,抽出 slug / version / name 等关键字段。
|
||||
|
||||
为避免硬性依赖 PyYAML,这里用一个**足够用**的轻量正则 parser:只抽取
|
||||
我们真正关心的字段(顶层 `name` / `version` / `author`,嵌套 `openclaw.slug`
|
||||
与 `openclaw.category`)。更复杂的 YAML 结构请自行用 PyYAML 解析。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
SKILL_MD = "SKILL.md"
|
||||
SKILL_ROOT_ENV = "JIANGCHANG_E2E_SKILL_ROOT"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
root: str
|
||||
slug: str
|
||||
name: str
|
||||
version: str
|
||||
author: str = ""
|
||||
category: str = ""
|
||||
|
||||
|
||||
def discover_skill_root(start: Optional[str] = None) -> str:
|
||||
"""定位 skill 根目录。
|
||||
|
||||
优先级:
|
||||
1. 环境变量 ``JIANGCHANG_E2E_SKILL_ROOT``(由调用方或测试入口提前注入);
|
||||
2. 从 ``start``(默认 cwd)向上回溯,直到找到含 ``SKILL.md`` 的目录。
|
||||
"""
|
||||
env = (os.environ.get(SKILL_ROOT_ENV) or "").strip()
|
||||
if env:
|
||||
env_abs = os.path.abspath(env)
|
||||
if os.path.isfile(os.path.join(env_abs, SKILL_MD)):
|
||||
return env_abs
|
||||
cur = Path(start or os.getcwd()).resolve()
|
||||
for parent in [cur, *cur.parents]:
|
||||
if (parent / SKILL_MD).exists():
|
||||
return str(parent)
|
||||
raise FileNotFoundError(
|
||||
f"未能在 {start or os.getcwd()} 及其父目录中找到 {SKILL_MD}。"
|
||||
f"请设置环境变量 {SKILL_ROOT_ENV}=<skill 根目录绝对路径>。"
|
||||
)
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||
|
||||
|
||||
def _extract_frontmatter(text: str) -> str:
|
||||
match = _FRONTMATTER_RE.match(text)
|
||||
if not match:
|
||||
raise ValueError("SKILL.md 缺少 YAML frontmatter(--- ... ---)。")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _strip_quotes(value: str) -> str:
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
|
||||
return v[1:-1]
|
||||
return v
|
||||
|
||||
|
||||
def parse_skill_md(skill_root: str) -> SkillInfo:
|
||||
"""解析 SKILL.md 的 frontmatter,返回 SkillInfo。"""
|
||||
md_path = Path(skill_root) / SKILL_MD
|
||||
if not md_path.exists():
|
||||
raise FileNotFoundError(f"SKILL.md 不存在:{md_path}")
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
fm = _extract_frontmatter(text)
|
||||
|
||||
top: dict[str, str] = {}
|
||||
openclaw: dict[str, str] = {}
|
||||
|
||||
lines = fm.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
# 顶层 key: value
|
||||
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
||||
if m:
|
||||
key, rest = m.group(1), m.group(2)
|
||||
if rest.strip() == "":
|
||||
# 嵌套块,收集 2 空格缩进的子项
|
||||
i += 1
|
||||
sub: dict[str, str] = {}
|
||||
while i < len(lines):
|
||||
sub_line = lines[i]
|
||||
if sub_line.strip() == "" or sub_line.lstrip().startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
if not sub_line.startswith(" "):
|
||||
break
|
||||
sm = re.match(r"^\s+([A-Za-z_][\w-]*):\s*(.*)$", sub_line)
|
||||
if sm:
|
||||
sub[sm.group(1)] = _strip_quotes(sm.group(2))
|
||||
i += 1
|
||||
if key == "metadata":
|
||||
# openclaw 在 metadata 下再下一层:
|
||||
# metadata:
|
||||
# openclaw:
|
||||
# slug: ...
|
||||
# 上面 sub 已经拿到的只是 "openclaw" 这个 key。为稳妥起见,回头
|
||||
# 用一个更宽松的扫描:直接找所有含 `slug:` `category:` 的行。
|
||||
pass
|
||||
continue
|
||||
top[key] = _strip_quotes(rest)
|
||||
i += 1
|
||||
|
||||
# openclaw.slug / category 用宽松扫描兜底(无论嵌套几层)
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("slug:") and "slug" not in openclaw:
|
||||
openclaw["slug"] = _strip_quotes(stripped.split(":", 1)[1])
|
||||
elif stripped.startswith("category:") and "category" not in openclaw:
|
||||
openclaw["category"] = _strip_quotes(stripped.split(":", 1)[1])
|
||||
|
||||
return SkillInfo(
|
||||
root=os.path.abspath(skill_root),
|
||||
slug=openclaw.get("slug", "") or Path(skill_root).name,
|
||||
name=top.get("name", Path(skill_root).name),
|
||||
version=top.get("version", "0.0.0"),
|
||||
author=top.get("author", ""),
|
||||
category=openclaw.get("category", ""),
|
||||
)
|
||||
69
src/jiangchang_desktop_sdk/testing/healthcheck.py
Normal file
69
src/jiangchang_desktop_sdk/testing/healthcheck.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""技能健康检查。
|
||||
|
||||
检查项:
|
||||
1. SKILL.md 成功解析,含 slug / version。
|
||||
2. ``python scripts/main.py health`` 命令退出码为 0(每个 skill 都约定实现)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .config import SkillInfo
|
||||
|
||||
logger = logging.getLogger("jiangchang_desktop_sdk.testing.healthcheck")
|
||||
|
||||
|
||||
class HealthCheckError(RuntimeError):
|
||||
"""健康检查失败。"""
|
||||
|
||||
|
||||
def skill_healthcheck(
|
||||
skill_info: SkillInfo,
|
||||
*,
|
||||
run_cli: bool = True,
|
||||
timeout_s: float = 15.0,
|
||||
) -> None:
|
||||
"""对一个 skill 跑本地健康检查。失败抛 ``HealthCheckError``。"""
|
||||
if not skill_info.slug:
|
||||
raise HealthCheckError(f"SKILL.md 未定义 openclaw.slug:{skill_info.root}")
|
||||
if not skill_info.version or skill_info.version == "0.0.0":
|
||||
raise HealthCheckError(f"SKILL.md 未定义 version:{skill_info.root}")
|
||||
|
||||
if not run_cli:
|
||||
return
|
||||
|
||||
main_py = os.path.join(skill_info.root, "scripts", "main.py")
|
||||
if not os.path.isfile(main_py):
|
||||
logger.warning("skill_healthcheck: %s 不存在,跳过 CLI health 检查", main_py)
|
||||
return
|
||||
|
||||
cmd = [sys.executable, main_py, "health"]
|
||||
logger.debug("skill_healthcheck: 运行 %s (cwd=%s)", cmd, skill_info.root)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
cwd=skill_info.root,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HealthCheckError(
|
||||
f"skill_healthcheck: '{' '.join(cmd)}' 超时 {timeout_s}s"
|
||||
) from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HealthCheckError(
|
||||
f"skill_healthcheck: '{' '.join(cmd)}' 退出码 {proc.returncode}\n"
|
||||
f"stdout: {proc.stdout[:600]}\n"
|
||||
f"stderr: {proc.stderr[:600]}"
|
||||
)
|
||||
logger.info(
|
||||
"skill_healthcheck: %s v%s OK", skill_info.slug, skill_info.version
|
||||
)
|
||||
82
src/jiangchang_desktop_sdk/testing/host_api.py
Normal file
82
src/jiangchang_desktop_sdk/testing/host_api.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""匠厂 Host HTTP API 的 Python 轻封装。
|
||||
|
||||
使用 stdlib urllib,避免为 SDK 引入新运行时依赖。当前只暴露本模块
|
||||
真正需要的端点(健康探活、agent 列表),**不包含任何会话删除/会话
|
||||
文件系统操作**——测试侧要保持"只通过 UI 操作,不走底层接口"的原则,
|
||||
后续如需访问其他 host-api 端点请在此处按需添加读型方法。
|
||||
|
||||
Host API 默认端口:`13210`(见 jiangchang/electron/utils/config.ts 中 `CLAWX_HOST_API`)。
|
||||
可通过环境变量 ``CLAWX_PORT_CLAWX_HOST_API`` 或本类的 ``port`` 参数覆盖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DEFAULT_PORT = 13210
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
|
||||
logger = logging.getLogger("jiangchang_desktop_sdk.testing.host_api")
|
||||
|
||||
|
||||
class HostAPIError(RuntimeError):
|
||||
"""Host API 调用失败(网络错误或非 2xx 响应)。"""
|
||||
|
||||
|
||||
class HostAPIClient:
|
||||
def __init__(
|
||||
self,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: Optional[int] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
self.host = host
|
||||
env_port = os.environ.get("CLAWX_PORT_CLAWX_HOST_API")
|
||||
self.port = port or (int(env_port) if env_port else DEFAULT_PORT)
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
except urllib.error.HTTPError as exc:
|
||||
payload: Any
|
||||
try:
|
||||
payload = json.loads(exc.read().decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
payload = str(exc)
|
||||
raise HostAPIError(f"{method} {path} -> HTTP {exc.code}: {payload}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise HostAPIError(f"{method} {path} -> {exc}") from exc
|
||||
|
||||
def ping(self) -> bool:
|
||||
try:
|
||||
self.list_agents()
|
||||
return True
|
||||
except HostAPIError:
|
||||
return False
|
||||
|
||||
def list_agents(self) -> Dict[str, Any]:
|
||||
return self._request("GET", "/api/agents") or {}
|
||||
48
src/jiangchang_desktop_sdk/types.py
Normal file
48
src/jiangchang_desktop_sdk/types.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
|
||||
@dataclass
|
||||
class JiangchangMessage:
|
||||
"""单条会话消息"""
|
||||
id: str
|
||||
role: str # 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: str
|
||||
timestamp: float
|
||||
is_error: bool = False
|
||||
tool_call_id: Optional[str] = None
|
||||
tool_name: Optional[str] = None
|
||||
tool_status: Optional[str] = None # 'running' | 'completed' | 'error'
|
||||
thinking_content: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class AskOptions:
|
||||
"""ask() 方法的选项"""
|
||||
timeout: int = 120000 # 毫秒
|
||||
wait_for_tools: bool = True
|
||||
agent_id: str = "main"
|
||||
# 拟人化输入:每个字符间延迟(毫秒)。0 = 直接 fill()。
|
||||
typing_delay_ms: int = 25
|
||||
# 发送方式:True=按 Enter 键;False=点击发送按钮
|
||||
use_enter_key: bool = True
|
||||
# 每次 ask 前是否自动新建任务(避免上下文污染)
|
||||
new_task: bool = True
|
||||
# 流式输出判稳阈值(秒):助手消息文本连续该秒数无增长视为完成
|
||||
stable_seconds: float = 3.0
|
||||
# 轮询间隔(秒)
|
||||
poll_interval: float = 0.5
|
||||
|
||||
@dataclass
|
||||
class LaunchOptions:
|
||||
"""launch_app() 方法的选项"""
|
||||
executable_path: Optional[str] = None # 默认从 JIANGCHANG_E2E_APP_PATH 环境变量读取
|
||||
cdp_port: int = 9222
|
||||
startup_timeout: int = 30000
|
||||
headless: bool = False # 是否无头模式运行
|
||||
|
||||
@dataclass
|
||||
class AssertOptions:
|
||||
"""assert_contains() 方法的选项"""
|
||||
timeout: int = 5000
|
||||
match_mode: str = "contains" # 'contains' | 'regex' | 'exact'
|
||||
message_index: int = -1 # -1 表示最后一条 assistant 消息
|
||||
include_tools: bool = False
|
||||
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
|
||||
79
src/jiangchang_skill_core/__init__.py
Normal file
79
src/jiangchang_skill_core/__init__.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from .client import EntitlementClient
|
||||
from .config import ensure_env_file, get, get_bool, get_float, get_int
|
||||
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
|
||||
from .guard import enforce_entitlement
|
||||
from .models import EntitlementResult
|
||||
from .runtime_env import (
|
||||
apply_cli_local_defaults,
|
||||
find_chrome_executable,
|
||||
get_data_root,
|
||||
get_sibling_skills_root,
|
||||
get_skills_root,
|
||||
get_user_id,
|
||||
platform_default_data_root,
|
||||
)
|
||||
from .unified_logging import (
|
||||
attach_unified_file_handler,
|
||||
ensure_trace_for_process,
|
||||
get_skill_log_file_path,
|
||||
get_skill_logger,
|
||||
get_unified_logs_dir,
|
||||
setup_skill_logging,
|
||||
subprocess_env_with_trace,
|
||||
)
|
||||
from .media_assets import (
|
||||
MediaAssetsStatus,
|
||||
background_music_issue,
|
||||
ensure_media_assets,
|
||||
pick_background_music,
|
||||
probe_ffmpeg,
|
||||
probe_media_assets,
|
||||
resolve_ffmpeg,
|
||||
resolve_ffprobe,
|
||||
resolve_media_assets_root,
|
||||
resolve_music_dir,
|
||||
)
|
||||
|
||||
try:
|
||||
from . import rpa
|
||||
except ImportError:
|
||||
rpa = None # type: ignore[assignment,misc]
|
||||
|
||||
__all__ = [
|
||||
"EntitlementClient",
|
||||
"EntitlementDeniedError",
|
||||
"EntitlementError",
|
||||
"EntitlementResult",
|
||||
"EntitlementServiceError",
|
||||
"MediaAssetsStatus",
|
||||
"background_music_issue",
|
||||
"ensure_media_assets",
|
||||
"probe_ffmpeg",
|
||||
"probe_media_assets",
|
||||
"pick_background_music",
|
||||
"resolve_ffmpeg",
|
||||
"resolve_ffprobe",
|
||||
"resolve_media_assets_root",
|
||||
"resolve_music_dir",
|
||||
"apply_cli_local_defaults",
|
||||
"attach_unified_file_handler",
|
||||
"ensure_env_file",
|
||||
"enforce_entitlement",
|
||||
"ensure_trace_for_process",
|
||||
"find_chrome_executable",
|
||||
"get",
|
||||
"get_bool",
|
||||
"get_data_root",
|
||||
"get_float",
|
||||
"get_int",
|
||||
"get_sibling_skills_root",
|
||||
"get_skills_root",
|
||||
"get_skill_log_file_path",
|
||||
"get_skill_logger",
|
||||
"get_unified_logs_dir",
|
||||
"get_user_id",
|
||||
"platform_default_data_root",
|
||||
"rpa",
|
||||
"setup_skill_logging",
|
||||
"subprocess_env_with_trace",
|
||||
]
|
||||
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
|
||||
740
src/jiangchang_skill_core/media_assets.py
Normal file
740
src/jiangchang_skill_core/media_assets.py
Normal file
@@ -0,0 +1,740 @@
|
||||
"""共享媒体资源解析:背景音乐、字体、水印与 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 Optional
|
||||
|
||||
MEDIA_ASSETS_ZIP_URL = (
|
||||
"https://git.jc2009.com/client-commons/media-assets/archive/main.zip"
|
||||
)
|
||||
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 = 128
|
||||
|
||||
|
||||
def _is_git_lfs_pointer(path: Path) -> bool:
|
||||
try:
|
||||
head = path.read_bytes()[:256]
|
||||
except OSError:
|
||||
return False
|
||||
return head.startswith(_GIT_LFS_POINTER_PREFIX)
|
||||
|
||||
|
||||
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: Path) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.suffix.lower() not in _AUDIO_EXTENSIONS:
|
||||
return False
|
||||
if _is_git_lfs_pointer(path):
|
||||
return False
|
||||
try:
|
||||
if path.stat().st_size < _MIN_AUDIO_BYTES:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@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 _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],
|
||||
) -> 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"
|
||||
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
if extracted.exists():
|
||||
shutil.rmtree(extracted, ignore_errors=True)
|
||||
|
||||
_download_zip(MEDIA_ASSETS_ZIP_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) -> MediaAssetsStatus:
|
||||
if not _needs_music_content_repair(status):
|
||||
return status
|
||||
if _media_assets_root_overridden(None):
|
||||
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):
|
||||
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):
|
||||
return _maybe_ensure_ffmpeg_tools(
|
||||
root,
|
||||
_maybe_repair_music_content(
|
||||
root,
|
||||
_inspect_media_assets(root, source="zip"),
|
||||
),
|
||||
)
|
||||
|
||||
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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
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 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))
|
||||
28
src/jiangchang_skill_core/rpa/artifacts.py
Normal file
28
src/jiangchang_skill_core/rpa/artifacts.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""RPA 失败存证路径与截图。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _artifacts_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:
|
||||
"""返回 {data_dir}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.{ext}"""
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
directory = os.path.join(data_dir, "rpa-artifacts", batch_id)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
return os.path.join(directory, f"{tag}_{ts}.{ext}")
|
||||
|
||||
|
||||
async def capture_failure(page, data_dir: str, batch_id: str, tag: str) -> str:
|
||||
"""受 OPENCLAW_ARTIFACTS_ON_FAILURE 控制,截图并返回路径;禁用时返回空字符串。"""
|
||||
if not _artifacts_enabled():
|
||||
return ""
|
||||
path = artifact_path(data_dir, batch_id, tag, ext="png")
|
||||
await page.screenshot(path=path, full_page=True)
|
||||
return path
|
||||
58
src/jiangchang_skill_core/rpa/browser.py
Normal file
58
src/jiangchang_skill_core/rpa/browser.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""统一 persistent context 启动封装(仅浏览器自动化,不负责录屏)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..runtime_env import find_chrome_executable
|
||||
from .stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
stealth_enabled,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import BrowserContext
|
||||
|
||||
|
||||
async def launch_persistent_browser(
|
||||
playwright,
|
||||
*,
|
||||
profile_dir: str,
|
||||
channel: str = "chrome",
|
||||
executable_path: str | None = None,
|
||||
headless: bool | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
) -> "BrowserContext":
|
||||
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
||||
|
||||
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
||||
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
||||
"""
|
||||
if headless is None:
|
||||
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
headless = v in ("1", "true", "yes", "on")
|
||||
|
||||
chrome = executable_path or find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
||||
)
|
||||
|
||||
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
|
||||
launch_kwargs: dict = dict(
|
||||
user_data_dir=profile_dir,
|
||||
headless=headless,
|
||||
executable_path=chrome,
|
||||
locale="zh-CN",
|
||||
no_viewport=True,
|
||||
args=args,
|
||||
)
|
||||
if ignore is not None:
|
||||
launch_kwargs["ignore_default_args"] = ignore
|
||||
|
||||
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
|
||||
if stealth_enabled():
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
return context
|
||||
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
|
||||
66
src/jiangchang_skill_core/rpa/stealth.py
Normal file
66
src/jiangchang_skill_core/rpa/stealth.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""淡化 Playwright 自动化指纹,降低 baxia/x5sec 风控命中率。
|
||||
|
||||
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0(或 false/off/no)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||||
try {
|
||||
window.chrome = window.chrome || {};
|
||||
window.chrome.runtime = window.chrome.runtime || {};
|
||||
} catch (e) {}
|
||||
try {
|
||||
const orig = navigator.permissions && navigator.permissions.query;
|
||||
if (orig) {
|
||||
navigator.permissions.query = (p) =>
|
||||
p && p.name === 'notifications'
|
||||
? Promise.resolve({ state: Notification.permission })
|
||||
: orig(p);
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||
} catch (e) {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||
} catch (e) {}
|
||||
try {
|
||||
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
||||
WebGLRenderingContext.prototype.getParameter = function (p) {
|
||||
if (p === 37445) return 'Intel Inc.';
|
||||
if (p === 37446) return 'Intel Iris OpenGL Engine';
|
||||
return getParameter.call(this, p);
|
||||
};
|
||||
} catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def stealth_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def persistent_context_launch_parts(
|
||||
*, extra_args: list[str] | None = None
|
||||
) -> tuple[list[str], list[str] | None]:
|
||||
"""返回 (args, ignore_default_args)。ignore_default_args 为 None 时不要传给 launch。"""
|
||||
args = ["--start-maximized"]
|
||||
if extra_args:
|
||||
args = args + list(extra_args)
|
||||
if not stealth_enabled():
|
||||
return args, None
|
||||
if "--disable-blink-features=AutomationControlled" not in args:
|
||||
args.append("--disable-blink-features=AutomationControlled")
|
||||
return args, ["--enable-automation"]
|
||||
483
src/jiangchang_skill_core/rpa/video_session.py
Normal file
483
src/jiangchang_skill_core/rpa/video_session.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
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,
|
||||
)
|
||||
|
||||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||
"FontName=Arial,FontSize=14,"
|
||||
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||||
"Outline=2,Shadow=1,Alignment=2"
|
||||
)
|
||||
|
||||
_CAPTURE_FRAMERATE = 15
|
||||
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
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_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,
|
||||
)
|
||||
|
||||
|
||||
def _compose_capture_mp4(
|
||||
ffmpeg_exe: Path,
|
||||
capture_path: Path,
|
||||
srt_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
music_file: Optional[Path] = None,
|
||||
music_volume: float = 0.15,
|
||||
) -> tuple[bool, str]:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
srt_esc = _escape_srt_path(str(srt_path.resolve()))
|
||||
subtitle_filter = (
|
||||
f"subtitles='{srt_esc}'"
|
||||
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
|
||||
)
|
||||
|
||||
if music_file and music_file.is_file():
|
||||
filter_complex = (
|
||||
f"[0:v]{subtitle_filter}[vout];"
|
||||
f"[1:a]volume={music_volume},apad[aout]"
|
||||
)
|
||||
cmd_tail = [
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_path),
|
||||
"-i",
|
||||
str(music_file),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(output_path),
|
||||
]
|
||||
else:
|
||||
cmd_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)
|
||||
|
||||
|
||||
class RpaVideoSession:
|
||||
"""Service/RPA 层统一 ffmpeg 桌面录屏;OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
skill_slug: str,
|
||||
skill_data_dir: str,
|
||||
batch_id: str,
|
||||
title: str = "",
|
||||
) -> None:
|
||||
self.skill_slug = skill_slug
|
||||
self.skill_data_dir = os.path.abspath(skill_data_dir)
|
||||
self.batch_id = batch_id
|
||||
self.title = title
|
||||
self.enabled = _record_video_enabled()
|
||||
self.warnings: List[str] = []
|
||||
self.output_video_path: Optional[str] = None
|
||||
self.capture_path: Optional[str] = None
|
||||
self.artifacts: Dict[str, Any] = {}
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_batch = "".join(c if c.isalnum() or c in "-_" else "_" for c in batch_id)
|
||||
self._video_stem = f"{skill_slug}_{ts}_{safe_batch}"
|
||||
|
||||
self._videos_dir = os.path.join(self.skill_data_dir, "videos")
|
||||
self._artifact_root = os.path.join(
|
||||
self.skill_data_dir, "rpa-artifacts", batch_id
|
||||
)
|
||||
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
|
||||
self._logs_dir = os.path.join(self._artifact_root, "logs")
|
||||
self._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log")
|
||||
self._capture_path = os.path.join(self._artifact_root, "capture.mp4")
|
||||
self._srt_path = os.path.join(self._subtitles_dir, f"{self._video_stem}.srt")
|
||||
self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
|
||||
|
||||
self._steps: List[_StepEntry] = []
|
||||
self._t0: Optional[float] = None
|
||||
self._ffmpeg_proc: Optional[subprocess.Popen] = None
|
||||
self._ffmpeg_record_log_file: Optional[IO[str]] = None
|
||||
|
||||
if self.enabled:
|
||||
os.makedirs(self._videos_dir, exist_ok=True)
|
||||
os.makedirs(self._subtitles_dir, exist_ok=True)
|
||||
os.makedirs(self._logs_dir, exist_ok=True)
|
||||
os.makedirs(self._artifact_root, exist_ok=True)
|
||||
|
||||
def _record_log_hint(self) -> str:
|
||||
if self.enabled and self._ffmpeg_record_log_path:
|
||||
return f"; record_log={self._ffmpeg_record_log_path}"
|
||||
return ""
|
||||
|
||||
def _append_warning(self, code: str, detail: str = "") -> None:
|
||||
msg = code if not detail else f"{code}: {detail}"
|
||||
if code in (
|
||||
"ffmpeg_record_start_failed",
|
||||
"ffmpeg_capture_missing",
|
||||
"ffmpeg_compose_failed",
|
||||
):
|
||||
msg += self._record_log_hint()
|
||||
self.warnings.append(msg)
|
||||
|
||||
def step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
self.add_step(text, duration=duration)
|
||||
|
||||
def add_step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
"""记录一步骤字幕(相对录屏开始时间)。"""
|
||||
if not self.enabled:
|
||||
return
|
||||
if self._t0 is None:
|
||||
self._t0 = time.monotonic()
|
||||
elapsed = time.monotonic() - self._t0
|
||||
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
|
||||
|
||||
def _close_ffmpeg_record_log(self) -> None:
|
||||
fh = self._ffmpeg_record_log_file
|
||||
self._ffmpeg_record_log_file = None
|
||||
if fh is None:
|
||||
return
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _start_ffmpeg_capture(self) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
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 __aenter__(self) -> "RpaVideoSession":
|
||||
if self.enabled:
|
||||
self._t0 = time.monotonic()
|
||||
if self.title:
|
||||
self.add_step(self.title, duration=3.0)
|
||||
self._start_ffmpeg_capture()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
if self.enabled:
|
||||
self._stop_ffmpeg_capture()
|
||||
await self.finalize()
|
||||
|
||||
async def finalize(self) -> None:
|
||||
"""停止录制后合成最终 MP4;失败只记 warning,不抛异常。"""
|
||||
if not self.enabled:
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
if not self._steps and self.title:
|
||||
self.add_step(self.title)
|
||||
|
||||
try:
|
||||
capture = Path(self._capture_path)
|
||||
if not capture.is_file() or capture.stat().st_size <= 0:
|
||||
self._append_warning("ffmpeg_capture_missing")
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
self.capture_path = str(capture.resolve())
|
||||
|
||||
entries = list(self._steps)
|
||||
if entries:
|
||||
entries[-1].duration = max(entries[-1].duration, 5.0)
|
||||
try:
|
||||
_generate_srt(entries, self._srt_path)
|
||||
except Exception as exc:
|
||||
self.warnings.append(f"subtitle_write_failed: {exc}")
|
||||
|
||||
music = _resolve_music_file(self.warnings)
|
||||
if music is None and "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
|
||||
|
||||
srt_file = Path(self._srt_path)
|
||||
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
|
||||
|
||||
ok, err = _compose_capture_mp4(
|
||||
ffmpeg_exe,
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
music_file=music,
|
||||
)
|
||||
if not ok and music is not None:
|
||||
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||||
ok, err = _compose_capture_mp4(
|
||||
ffmpeg_exe,
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
music_file=None,
|
||||
)
|
||||
if not ok and not use_srt:
|
||||
ok, err = _run_ffmpeg(
|
||||
ffmpeg_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
|
||||
)
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"path": self.output_video_path,
|
||||
"capture_path": self.capture_path,
|
||||
"record_log_path": record_log,
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
def to_artifact_dict(self) -> Dict[str, Any]:
|
||||
data = self.summary()
|
||||
data["batch_id"] = self.batch_id
|
||||
return data
|
||||
227
src/jiangchang_skill_core/runtime_env.py
Normal file
227
src/jiangchang_skill_core/runtime_env.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
|
||||
|
||||
宿主在 skills.entries 与 Gateway 中注入:
|
||||
- PATH 前缀:{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/(Scripts|bin)
|
||||
- VIRTUAL_ENV、JIANGCHANG_PYTHON_EXE
|
||||
Playwright 使用本机 Chrome/Edge(launch 时 channel=chrome|msedge),不依赖宿主下载的 Chromium 包。
|
||||
技能勿在仓库内维护独立 .venv;共享逻辑请使用本仓库发布的 ``jiangchang-platform-kit`` 包,
|
||||
其余运行时依赖(如 Playwright、模型 SDK)由宿主或技能各自的环境策略决定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启
|
||||
CLI_LOCAL_DEV_ENABLED = True
|
||||
|
||||
# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改)
|
||||
DEFAULT_LOCAL_USER_ID = "10032"
|
||||
|
||||
# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径
|
||||
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
|
||||
|
||||
# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列)
|
||||
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
|
||||
|
||||
|
||||
def platform_default_data_root() -> str:
|
||||
if sys.platform == "win32":
|
||||
return WIN_DEFAULT_DATA_ROOT
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip()
|
||||
if env:
|
||||
return env
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip() or "_anon"
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
if not path or not os.path.isdir(path):
|
||||
return False
|
||||
for marker in (
|
||||
"llm-manager",
|
||||
"content-manager",
|
||||
"account-manager",
|
||||
"sohu-publisher",
|
||||
"toutiao-publisher",
|
||||
"logistics-tracker",
|
||||
"api-key-vault",
|
||||
"receive-order",
|
||||
):
|
||||
if os.path.isdir(os.path.join(path, marker)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_skills_root() -> str:
|
||||
"""
|
||||
并列技能安装目录(其下为「技能 slug」子目录)。
|
||||
|
||||
优先级:
|
||||
1) JIANGCHANG_SKILLS_ROOT
|
||||
2) CLAW_SKILLS_ROOT
|
||||
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||
5) 其他平台:~/.openclaw/skills
|
||||
"""
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||
if sys.platform == "win32" and not app:
|
||||
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
|
||||
if app:
|
||||
nested = os.path.join(app, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(app):
|
||||
return os.path.normpath(app)
|
||||
|
||||
if sys.platform == "win32":
|
||||
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
|
||||
return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT)
|
||||
|
||||
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
|
||||
|
||||
|
||||
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
"""
|
||||
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
|
||||
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
|
||||
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
||||
推断失败时再读上述环境变量,最后回落 get_skills_root()。
|
||||
"""
|
||||
if skill_scripts_dir:
|
||||
scripts = os.path.abspath(skill_scripts_dir)
|
||||
skill_root = os.path.dirname(scripts)
|
||||
inferred = os.path.dirname(skill_root)
|
||||
if _looks_like_skills_root(inferred):
|
||||
return os.path.normpath(inferred)
|
||||
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
return get_skills_root()
|
||||
|
||||
|
||||
def apply_cli_local_defaults() -> None:
|
||||
"""
|
||||
在 CLI 最早阶段调用(main.py 在 import 业务包之前)。
|
||||
宿主已设置 JIANGCHANG_* 时不会覆盖。
|
||||
"""
|
||||
enabled = CLI_LOCAL_DEV_ENABLED
|
||||
if not enabled:
|
||||
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip():
|
||||
default_root = platform_default_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = default_root
|
||||
os.environ.setdefault("CLAW_DATA_ROOT", default_root)
|
||||
if not (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||
os.environ.setdefault("CLAW_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
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
|
||||
按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。
|
||||
|
||||
实现为各技能 scripts/jiangchang_skill_core/ 下的同名副本之源,修改后请同步到各技能。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,17 +10,23 @@ import os
|
||||
import sys
|
||||
import uuid
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .runtime_env import get_data_root, get_user_id
|
||||
|
||||
_skill_slug: str = ""
|
||||
_logger_name: str = ""
|
||||
|
||||
# 避免 logging 内部错误再向 stderr 打印 "--- Logging error ---"
|
||||
logging.raiseExceptions = False
|
||||
|
||||
|
||||
def get_unified_logs_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), "logs")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
@@ -32,11 +36,27 @@ def get_skill_log_file_path() -> str:
|
||||
if override:
|
||||
parent = os.path.dirname(os.path.abspath(override))
|
||||
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.join(get_unified_logs_dir(), "jiangchang.log")
|
||||
|
||||
|
||||
def _can_open_log_file(log_path: str) -> Tuple[bool, str]:
|
||||
"""探测日志文件是否可追加写入(真实 open,非 os.access)。"""
|
||||
try:
|
||||
parent = os.path.dirname(os.path.abspath(log_path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(log_path, "a", encoding="utf-8"):
|
||||
pass
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def ensure_trace_for_process() -> str:
|
||||
"""本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。"""
|
||||
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||
@@ -83,44 +103,72 @@ def _backup_count() -> int:
|
||||
return 30
|
||||
|
||||
|
||||
def _clear_logger_handlers(log: logging.Logger) -> None:
|
||||
for h in list(log.handlers):
|
||||
log.removeHandler(h)
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _attach_fallback_handler(log: logging.Logger) -> None:
|
||||
"""文件日志不可用时仅挂 NullHandler,不向 stderr 打印 logging 异常。"""
|
||||
log.addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
||||
"""
|
||||
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
|
||||
须在进程早期调用(如 CLI main 在业务日志之前)。
|
||||
文件日志不可写时降级为 NullHandler,不影响 CLI 返回码。
|
||||
"""
|
||||
global _skill_slug, _logger_name
|
||||
logging.raiseExceptions = False
|
||||
ensure_trace_for_process()
|
||||
_skill_slug = skill_slug
|
||||
_logger_name = logger_name
|
||||
|
||||
log = logging.getLogger(logger_name)
|
||||
if log.handlers:
|
||||
return
|
||||
_clear_logger_handlers(log)
|
||||
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)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(_SkillContextFilter())
|
||||
log.addHandler(fh)
|
||||
filt = _SkillContextFilter()
|
||||
|
||||
log_path = get_skill_log_file_path()
|
||||
ok, _err = _can_open_log_file(log_path)
|
||||
if ok:
|
||||
try:
|
||||
fh = TimedRotatingFileHandler(
|
||||
log_path,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=_backup_count(),
|
||||
encoding="utf-8",
|
||||
delay=False,
|
||||
)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(filt)
|
||||
log.addHandler(fh)
|
||||
except Exception:
|
||||
_attach_fallback_handler(log)
|
||||
else:
|
||||
_attach_fallback_handler(log)
|
||||
|
||||
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
):
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(logging.WARNING)
|
||||
sh.setFormatter(fmt)
|
||||
sh.addFilter(_SkillContextFilter())
|
||||
log.addHandler(sh)
|
||||
try:
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(logging.WARNING)
|
||||
sh.setFormatter(fmt)
|
||||
sh.addFilter(filt)
|
||||
log.addHandler(sh)
|
||||
except Exception:
|
||||
pass
|
||||
log.propagate = False
|
||||
|
||||
|
||||
@@ -141,18 +189,25 @@ def attach_unified_file_handler(
|
||||
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
|
||||
"""
|
||||
global _skill_slug
|
||||
logging.raiseExceptions = False
|
||||
ensure_trace_for_process()
|
||||
_skill_slug = skill_slug
|
||||
lg = logging.getLogger(logger_name)
|
||||
lg.handlers.clear()
|
||||
_clear_logger_handlers(lg)
|
||||
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)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(_SkillContextFilter())
|
||||
lg.addHandler(fh)
|
||||
filt = _SkillContextFilter()
|
||||
|
||||
ok, _err = _can_open_log_file(log_path)
|
||||
if ok:
|
||||
try:
|
||||
fh = logging.FileHandler(log_path, encoding="utf-8", delay=False)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(filt)
|
||||
lg.addHandler(fh)
|
||||
except Exception:
|
||||
_attach_fallback_handler(lg)
|
||||
else:
|
||||
_attach_fallback_handler(lg)
|
||||
lg.propagate = False
|
||||
return lg
|
||||
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.
|
||||
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()
|
||||
342
tests/test_media_assets.py
Normal file
342
tests/test_media_assets.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# -*- 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" * 256)
|
||||
(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 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" * 256)
|
||||
(music / "calm" / "a-calm.mp3").write_bytes(b"x" * 256)
|
||||
(music / "z-last.wav").write_bytes(b"x" * 256)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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" * 256)
|
||||
zf.writestr(f"{inner_name}/fonts/.keep", "")
|
||||
zf.writestr(f"{inner_name}/watermark/.keep", "")
|
||||
|
||||
|
||||
def test_zip_wrapper_layout_detected(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"
|
||||
|
||||
_build_archive_zip(archive, "media-assets-main")
|
||||
|
||||
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_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" * 256)
|
||||
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" * 256)
|
||||
(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
|
||||
42
tests/test_release_workflow.py
Normal file
42
tests/test_release_workflow.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Text-level checks that reusable-release-skill packages .env.example at package root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_WORKFLOW_PATH = os.path.join(
|
||||
_REPO_ROOT, ".github", "workflows", "reusable-release-skill.yaml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def workflow_text() -> str:
|
||||
with open(_WORKFLOW_PATH, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
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",
|
||||
)
|
||||
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()
|
||||
148
tests/test_video_session.py
Normal file
148
tests/test_video_session.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# -*- 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 MagicMock, patch
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||
|
||||
|
||||
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"])
|
||||
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._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()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
79
tools/ci_set_package_version.py
Normal file
79
tools/ci_set_package_version.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Patch pyproject.toml and __init__.py version for CI builds (not committed)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _read_base_version() -> str:
|
||||
text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
if not match:
|
||||
raise SystemExit("ERROR: version not found in pyproject.toml")
|
||||
base = match.group(1)
|
||||
return re.sub(r"\.post\d+$", "", base)
|
||||
|
||||
|
||||
def resolve_version() -> str:
|
||||
ref = os.environ.get("GITHUB_REF", "")
|
||||
if ref.startswith("refs/tags/v"):
|
||||
return ref.removeprefix("refs/tags/v")
|
||||
if ref.startswith("refs/tags/"):
|
||||
return ref.removeprefix("refs/tags/")
|
||||
|
||||
run_number = (
|
||||
os.environ.get("GITHUB_RUN_NUMBER")
|
||||
or os.environ.get("GITHUB_RUN_ID")
|
||||
or "0"
|
||||
)
|
||||
base = _read_base_version()
|
||||
return f"{base}.post{run_number}"
|
||||
|
||||
|
||||
def patch_version(version: str) -> None:
|
||||
pyproject = ROOT / "pyproject.toml"
|
||||
py_text = pyproject.read_text(encoding="utf-8")
|
||||
py_text, n = re.subn(
|
||||
r'^version\s*=\s*"[^"]*"',
|
||||
f'version = "{version}"',
|
||||
py_text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n != 1:
|
||||
raise SystemExit("ERROR: failed to patch version in pyproject.toml")
|
||||
pyproject.write_text(py_text, encoding="utf-8")
|
||||
|
||||
init_py = ROOT / "src" / "jiangchang_desktop_sdk" / "__init__.py"
|
||||
init_text = init_py.read_text(encoding="utf-8")
|
||||
init_text, n = re.subn(
|
||||
r'^__version__\s*=\s*"[^"]*"',
|
||||
f'__version__ = "{version}"',
|
||||
init_text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n != 1:
|
||||
raise SystemExit("ERROR: failed to patch __version__ in __init__.py")
|
||||
init_py.write_text(init_text, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
version = resolve_version()
|
||||
print(f"Resolved package version: {version}")
|
||||
patch_version(version)
|
||||
if os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
|
||||
fh.write(f"package_version={version}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except SystemExit as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
raise
|
||||
@@ -25,8 +25,15 @@
|
||||
.NOTES
|
||||
Requires: git, PowerShell 5+
|
||||
|
||||
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行:
|
||||
对 scripts/ 递归 PyArmor(-r),输出到包内 scripts/(与源码目录树一致),并复制 SKILL.md、references/、assets/(若存在)。
|
||||
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
|
||||
- 加密 scripts/(递归 PyArmor -r,输出到包内 scripts/,与源码目录树一致)
|
||||
- 复制 SKILL.md
|
||||
- 复制 references/(排除 REQUIREMENTS.md)
|
||||
- 复制 assets/
|
||||
- 复制 tests/
|
||||
- 复制 evals/(除 scripts 外均为明文;若目录不存在则跳过;复制时排除常见缓存与运行产物)
|
||||
- 若根目录存在 requirements.txt,则复制到包根(明文,不 PyArmor)
|
||||
- 若根目录存在 .env.example,则复制到包根(明文,不 PyArmor,用于首次运行时落盘用户 .env)
|
||||
本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。
|
||||
#>
|
||||
|
||||
@@ -46,7 +53,9 @@ $ErrorActionPreference = "Stop"
|
||||
function Invoke-Git {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
Write-Host ">> git $Args" -ForegroundColor DarkGray
|
||||
& cmd /c "git $Args"
|
||||
# 将 git 的 stderr 合并进 stdout,避免 PowerShell 在 $ErrorActionPreference=Stop 下
|
||||
# 把 "remote: ..." / "warning: LF -> CRLF" 等非错误输出误判为异常。
|
||||
& cmd /c "git $Args 2>&1"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
@@ -216,7 +225,9 @@ try {
|
||||
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
|
||||
}
|
||||
|
||||
$upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null)
|
||||
# 用 cmd /c + 2>&1 将 stderr 合并进 stdout,避免 PowerShell 在 $ErrorActionPreference=Stop
|
||||
# 下把首次发布时 "fatal: ambiguous argument '@{u}'" 之类的 stderr 误判为异常。
|
||||
$upstream = & cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1'
|
||||
$hasUpstream = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($DryRun) {
|
||||
|
||||
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"]
|
||||
161
uv.lock
generated
Normal file
161
uv.lock
generated
Normal file
@@ -0,0 +1,161 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ 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 = "idna"
|
||||
version = "3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "requests", specifier = ">=2.31.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user