From 35f4758da2d04640c664347f2da9af5774a1a51f Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 4 Apr 2026 10:35:02 +0800 Subject: [PATCH] Add OpenClaw skills, platform kit, and template docs Made-with: Cursor --- .claude/settings.local.json | 11 + CLAUDE.md | 112 ++ __pycache__/mcp_server.cpython-312.pyc | Bin 0 -> 6702 bytes .../.github/workflows/release_skill.yaml | 11 + account-manager/SKILL.md | 53 + account-manager/release.ps1 | 23 + account-manager/scripts/main.py | 1498 +++++++++++++++++ .../.github/workflows/release_skill.yaml | 11 + api-key-vault/SKILL.md | 54 + api-key-vault/release.ps1 | 220 +++ api-key-vault/scripts/vault.py | 105 ++ .../.github/workflows/release_skill.yaml | 11 + content-manager/SKILL.md | 82 + content-manager/content_manager/__init__.py | 1 + .../content_manager/cli/__init__.py | 1 + content-manager/content_manager/cli/app.py | 261 +++ content-manager/content_manager/config.py | 47 + content-manager/content_manager/constants.py | 103 ++ .../content_manager/db/__init__.py | 3 + .../content_manager/db/articles_repository.py | 122 ++ .../content_manager/db/connection.py | 113 ++ .../content_manager/db/images_repository.py | 88 + .../content_manager/db/prompts_repository.py | 114 ++ content-manager/content_manager/db/schema.py | 73 + .../content_manager/db/videos_repository.py | 100 ++ .../content_manager/services/__init__.py | 1 + .../services/article_service.py | 431 +++++ .../content_manager/services/file_store.py | 50 + .../content_manager/services/image_service.py | 171 ++ .../content_manager/services/video_service.py | 179 ++ .../content_manager/util/__init__.py | 1 + .../content_manager/util/argparse_zh.py | 74 + .../content_manager/util/timeutil.py | 36 + content-manager/release.ps1 | 220 +++ content-manager/scripts/main.py | 24 + .../workflows/reusable-release-skill.yaml | 139 ++ jiangchang-platform-kit/README.md | 7 + .../examples/flask_skill_update_or_create.py | 84 + .../workflows/use-reusable-release-skill.yaml | 12 + .../sdk/jiangchang_skill_core/__init__.py | 9 + .../sdk/jiangchang_skill_core/client.py | 63 + .../sdk/jiangchang_skill_core/errors.py | 10 + .../sdk/jiangchang_skill_core/guard.py | 22 + .../sdk/jiangchang_skill_core/models.py | 10 + jiangchang-platform-kit/sdk/pyproject.toml | 19 + jiangchang-platform-kit/tools/release.ps1 | 216 +++ .../.github/workflows/release_skill.yaml | 11 + llm-manager/SKILL.md | 119 ++ llm-manager/release.ps1 | 220 +++ llm-manager/scripts/db.py | 292 ++++ llm-manager/scripts/engines/api_engine.py | 28 + llm-manager/scripts/engines/base.py | 15 + llm-manager/scripts/engines/deepseek.py | 70 + llm-manager/scripts/engines/doubao.py | 222 +++ llm-manager/scripts/engines/kimi.py | 48 + llm-manager/scripts/engines/qianwen.py | 91 + llm-manager/scripts/engines/yiyan.py | 91 + llm-manager/scripts/engines/yuanbao.py | 94 ++ llm-manager/scripts/main.py | 575 +++++++ llm-manager/scripts/providers.py | 199 +++ .../.github/workflows/release_skill.yaml | 11 + logistics-tracker/SKILL.md | 53 + logistics-tracker/release.ps1 | 220 +++ logistics-tracker/scripts/main.py | 142 ++ skill-template/README.md | 2 + skill-template/docs/LOGGING.md | 59 + skill-template/release.ps1 | 23 + .../.github/workflows/release_skill.yaml | 11 + sohu-publisher/SKILL.md | 77 + sohu-publisher/release.ps1 | 220 +++ sohu-publisher/scripts/main.py | 563 +++++++ .../.github/workflows/release_skill.yaml | 13 + toutiao-publisher/.gitignore | 8 + toutiao-publisher/README.md | 40 + toutiao-publisher/SKILL.md | 52 + toutiao-publisher/docs/PORTABILITY.md | 56 + toutiao-publisher/docs/RUNTIME.md | 55 + toutiao-publisher/docs/SKILL_TYPES.md | 33 + toutiao-publisher/optional/README.md | 17 + toutiao-publisher/optional/paths_snippet.py | 92 + toutiao-publisher/optional/sqlite_minimal.py | 48 + toutiao-publisher/release.ps1 | 23 + toutiao-publisher/scripts/skill_main.py | 83 + 83 files changed, 8971 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 __pycache__/mcp_server.cpython-312.pyc create mode 100644 account-manager/.github/workflows/release_skill.yaml create mode 100644 account-manager/SKILL.md create mode 100644 account-manager/release.ps1 create mode 100644 account-manager/scripts/main.py create mode 100644 api-key-vault/.github/workflows/release_skill.yaml create mode 100644 api-key-vault/SKILL.md create mode 100644 api-key-vault/release.ps1 create mode 100644 api-key-vault/scripts/vault.py create mode 100644 content-manager/.github/workflows/release_skill.yaml create mode 100644 content-manager/SKILL.md create mode 100644 content-manager/content_manager/__init__.py create mode 100644 content-manager/content_manager/cli/__init__.py create mode 100644 content-manager/content_manager/cli/app.py create mode 100644 content-manager/content_manager/config.py create mode 100644 content-manager/content_manager/constants.py create mode 100644 content-manager/content_manager/db/__init__.py create mode 100644 content-manager/content_manager/db/articles_repository.py create mode 100644 content-manager/content_manager/db/connection.py create mode 100644 content-manager/content_manager/db/images_repository.py create mode 100644 content-manager/content_manager/db/prompts_repository.py create mode 100644 content-manager/content_manager/db/schema.py create mode 100644 content-manager/content_manager/db/videos_repository.py create mode 100644 content-manager/content_manager/services/__init__.py create mode 100644 content-manager/content_manager/services/article_service.py create mode 100644 content-manager/content_manager/services/file_store.py create mode 100644 content-manager/content_manager/services/image_service.py create mode 100644 content-manager/content_manager/services/video_service.py create mode 100644 content-manager/content_manager/util/__init__.py create mode 100644 content-manager/content_manager/util/argparse_zh.py create mode 100644 content-manager/content_manager/util/timeutil.py create mode 100644 content-manager/release.ps1 create mode 100644 content-manager/scripts/main.py create mode 100644 jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml create mode 100644 jiangchang-platform-kit/README.md create mode 100644 jiangchang-platform-kit/examples/flask_skill_update_or_create.py create mode 100644 jiangchang-platform-kit/examples/workflows/use-reusable-release-skill.yaml create mode 100644 jiangchang-platform-kit/sdk/jiangchang_skill_core/__init__.py create mode 100644 jiangchang-platform-kit/sdk/jiangchang_skill_core/client.py create mode 100644 jiangchang-platform-kit/sdk/jiangchang_skill_core/errors.py create mode 100644 jiangchang-platform-kit/sdk/jiangchang_skill_core/guard.py create mode 100644 jiangchang-platform-kit/sdk/jiangchang_skill_core/models.py create mode 100644 jiangchang-platform-kit/sdk/pyproject.toml create mode 100644 jiangchang-platform-kit/tools/release.ps1 create mode 100644 llm-manager/.github/workflows/release_skill.yaml create mode 100644 llm-manager/SKILL.md create mode 100644 llm-manager/release.ps1 create mode 100644 llm-manager/scripts/db.py create mode 100644 llm-manager/scripts/engines/api_engine.py create mode 100644 llm-manager/scripts/engines/base.py create mode 100644 llm-manager/scripts/engines/deepseek.py create mode 100644 llm-manager/scripts/engines/doubao.py create mode 100644 llm-manager/scripts/engines/kimi.py create mode 100644 llm-manager/scripts/engines/qianwen.py create mode 100644 llm-manager/scripts/engines/yiyan.py create mode 100644 llm-manager/scripts/engines/yuanbao.py create mode 100644 llm-manager/scripts/main.py create mode 100644 llm-manager/scripts/providers.py create mode 100644 logistics-tracker/.github/workflows/release_skill.yaml create mode 100644 logistics-tracker/SKILL.md create mode 100644 logistics-tracker/release.ps1 create mode 100644 logistics-tracker/scripts/main.py create mode 100644 skill-template/docs/LOGGING.md create mode 100644 skill-template/release.ps1 create mode 100644 sohu-publisher/.github/workflows/release_skill.yaml create mode 100644 sohu-publisher/SKILL.md create mode 100644 sohu-publisher/release.ps1 create mode 100644 sohu-publisher/scripts/main.py create mode 100644 toutiao-publisher/.github/workflows/release_skill.yaml create mode 100644 toutiao-publisher/.gitignore create mode 100644 toutiao-publisher/README.md create mode 100644 toutiao-publisher/SKILL.md create mode 100644 toutiao-publisher/docs/PORTABILITY.md create mode 100644 toutiao-publisher/docs/RUNTIME.md create mode 100644 toutiao-publisher/docs/SKILL_TYPES.md create mode 100644 toutiao-publisher/optional/README.md create mode 100644 toutiao-publisher/optional/paths_snippet.py create mode 100644 toutiao-publisher/optional/sqlite_minimal.py create mode 100644 toutiao-publisher/release.ps1 create mode 100644 toutiao-publisher/scripts/skill_main.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..21daa0e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs -I {} bash -c \"echo '=== {} ===' && ls -la '{}'\")", + "Bash(python scripts/main.py version)", + "Bash(python scripts/main.py health)", + "Bash(python scripts/main.py key list)", + "Bash(python scripts/main.py generate kimi \"测试\")" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65079c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Repo Is + +**OpenClaw** is a monorepo of installable "Skills" (plugins) for the Claw agent hosting platform (深圳匠厂科技). Each subdirectory is an independent skill that ships as an encrypted ZIP to jc2009.com and gets installed by a desktop/gateway host. + +Each skill is independently versioned and can have its own git remote (`http://120.25.191.12:3000/admin/.git`). The root `.git` tracks only the `skill-template` scaffold. + +## Running Skills + +No build step — all skills are plain Python 3.10+. Run directly: + +```bash +# Health / version check (every skill exposes these) +python scripts/skill_main.py health +python scripts/skill_main.py version + +# Account manager +python account-manager/scripts/main.py list all +python account-manager/scripts/main.py add "搜狐号" "13800138000" + +# Content manager +python content-manager/scripts/main.py get + +# API key vault +python api-key-vault/scripts/vault.py get 17track + +# Logistics +python logistics-tracker/scripts/main.py + +# Sohu publish +python sohu-publisher/scripts/main.py +``` + +## Releasing a Skill + +From the skill's directory: + +```powershell +.\release.ps1 # tag + push +.\release.ps1 -DryRun # preview only +.\release.ps1 -AutoCommit -CommitMessage "Release v1.0.5" +``` + +This delegates to `jiangchang-platform-kit/tools/release.ps1`, creates a semver git tag, and triggers the GitHub Actions workflow that encrypts scripts via PyArmor, packs a ZIP, and syncs metadata to jc2009.com. + +## Architecture + +### Skill Structure + +Every skill directory contains: +- `SKILL.md` — YAML frontmatter (name, version, slug, category, dependencies, allowed-tools) + Markdown body with usage triggers and env requirements. **Version here must stay in sync with the git tag.** +- `scripts/` — Python entry points +- `release.ps1` — delegates to platform kit + +### Environment Contract (`CLAW_*`) + +Skills locate user data via env vars: + +| Variable | Purpose | Dev default | +|---|---|---| +| `CLAW_DATA_ROOT` | User data root | `D:\claw-data` or `~/.claw-data` | +| `CLAW_USER_ID` | Workspace ID | `_anon` | +| `CLAW_SKILLS_ROOT` | Sibling skills dir | Parent of skill dir | + +Legacy aliases (`JIANGCHANG_DATA_ROOT`, etc.) are still detected; prefer `CLAW_*` in new code. + +Data path convention: `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{slug}/` (SQLite DBs, cache, temp). + +### Skill Types + +| Type | Pattern | Examples | +|---|---|---| +| A | Stateless tool | logistics-tracker | +| B | Local persistent (SQLite) | account-manager, content-manager, api-key-vault | +| C | Orchestrator (calls sibling skills via subprocess) | — | +| D | Hybrid (B + C) | sohu-publisher | + +Type C/D skills declare `dependencies.required` in SKILL.md and call siblings like: +```python +subprocess.run(["python", f"{skills_root}/account-manager/scripts/main.py", ...]) +``` + +### Shared Platform Kit (`jiangchang-platform-kit/`) + +- `sdk/jiangchang_skill_core` — `EntitlementClient`, `enforce_entitlement` (license checks) +- `.github/workflows/reusable-release-skill.yaml` — reusable CI called by every skill's release workflow +- `tools/release.ps1` — shared release script + +### CLI Conventions + +- Subcommands: `health` (offline quick check, exits 0/1), `version` (JSON output), custom actions +- Success prefix: `OK` or plain output +- Error prefix: `ERROR:` (e.g., `ERROR:REQUIRE_LOGIN`) +- Windows GBK fix: wrap stdout/stderr with `io.TextIOWrapper(..., encoding='utf-8')` + +## Key Docs + +| File | What it covers | +|---|---| +| `skill-template/README.md` | Onboarding, anti-patterns | +| `skill-template/docs/RUNTIME.md` | Full env var contract and fallback behavior | +| `skill-template/docs/SKILL_TYPES.md` | Type A–D classification checklist | +| `skill-template/docs/PORTABILITY.md` | Cross-platform encoding, path differences | +| `skill-template/docs/LOGGING.md` | `TimedRotatingFileHandler` setup | + +## Known Dev-Mode Issues + +- `account-manager/scripts/main.py` has `_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True` hardcoded with a fixed data path (`D:\jiangchang-data`) and user ID — disable before releasing. +- `toutiao-publisher` is a placeholder stub; publish logic is not yet implemented. diff --git a/__pycache__/mcp_server.cpython-312.pyc b/__pycache__/mcp_server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24a76a7b637e47c4d184f561489def5f1bacf629 GIT binary patch literal 6702 zcmd5=dvp_J7N40+l4;WPUEbhOTeKo=0YxYUt+iF^QdkPB6xbLtQ%Fpc?##r}nl3F+ z+RCG4d9)}>L6ovQnpKpy;O;rQp0oc1(>;`t{ljA?P4^$7_=ku6XYV(UB*n0Z>mNJk z%)Q@y-@W&H{qFtlH-EQSOc*@#_=}EzO~kNo(1-R=YT?de0>gSR2CKmsoFQCz4K8z{ zhLE|gMkjN!h6Jv2>FW$N1{@n_Hf z<6~v5bRS6=^Suvi(fZVQO$uvtK60-dhMiQam?U*nBxBk}YPB_K5707$R&sPcmN?Dy z2WVz9DWI1c)lJ)`kM^4JlXTOgx*2i0nQRu5`TFA+##OL7=Mz7kv2)gs>zFKUop9_v zT7^H>(#8uqM(6DR*4{`o68Yo2Hjptbt7qM1E?Wb=v}`$D&2n$BoGG;LGwD!2{X+3t zt#vIO>bo%V&Kc>-{^9dG!dJV)M|PS_Qul!LR)6Tg1?h6v?aOc1Hwqg)Zo1B1Z{=k} z1@(=lQ8bXMd|WhE*mwat5h=~6Xy)ylqh7Gq+k}lGY2!BVqJd)tFXy(a7?o#X25N-& zP6vrlFUXaMIKd0rB&1G0qU0^>;8v=hFcteK=74yGE@AkDpbdeluhCv{G!plQ1D$t96dkN-^#ueAouU zhabfm17kczozy99%_Nhw4QtjDm{0E`ofBdjT1gw9bFx+w<68gC230@7xPOwq(P!jJ ze#W|+9%9|i^13Jcj1Rf)iT2y_(EX;otJhZIf7@xFfk&&QXHwBj-X*FrpJ5Zu6FA1C zRb!80LNs*yvb@1^3r zrNw#GUgW6s;>C;UQ0H+7lHqM{hYp=8roAZa&{Efy(0+e0eHS;a94&#Gd_qjI+v*|Y zu~v^)sP_t@QE=3;P#zNk^&pEZ-+3EI59Y&oyb(_V?u-wV(1xB^z>y&l4U(|^GjZ&` zS~`Y2r9Y`kqp2fpfD7tdeqvz~wJH+IVni3kP$gnbdaqDBYrd%4u>N(?$g%Y_{9aW_FS;VR|9FmZf1_}BO-*sk~cJda?hK4-t2oOIDTdz zZRQWRGV{K}NK0OX(52@MO@8F?!h;L@y#Cd_3xkuN{W9y>A0h;vo_A~Vv|izz1%p|+ zcOyw4eV2a}?6#$i!ww{snsj{`PZJ;EQ24}BYS4d95K#QwKmu)|)|9VUv24XUKmgs8 zMDqdzi&AWAGOejzRaRDBUA>O>rlUzK=#kFs3=Qo6re%xCI|*tPFey!=`woT&28OR5 z3AGLso4m-oZeQ+D+Cm-O(p!gzuWS$X^o2j$E45#|eYq3*Sh-|r`Lb0jwLXR~U6S59 z68^L;)br`>%dIBwt6)U23#Yl-+2Ieu14lKPou$i{(9+&r(#3t@tL>q-qoIxiBOhNH zxpX*u_Hg*h6{s9N*?ar)&hWswk^Ws$_ikzXuCP3VrsOr{ua=cBU%6yi6%0UehqEU_ z7q^0Qps1zV@oyd3*Dqb(BAq{9Oy{Qq9vXSk#BO5kUct8B#fpT77fCr|pw`=X)@`d} zMT4@CqP~H12&_mV7ZNEzQmtIl*Sfs?M$yE3*Vl6%JInK;4!|O-fv1UjkaX0GDJvDv zE8kS_;8;dXD&J&h>jj6$&7lG2CvnIXIFwX~CMDUi!oZ?wj4~9C<-IO}LkY8JP<>i2 zLtn&MUY<9tJ{&r$MN|37IyIrU_!x7%?ns@#GC1SeK;CoL@M|-#O$}7N7MN%oq}JbxmmQ=g zeVv+pFmEWk$e-;u1~TUdlIIVlW`CKwATV)3fLidwusQpNIrq9b_dATxJMl5v59sM; zs^gY93+WY(RVzt6tJQ-w>jJ;E25Vjmtd{3Ckr|}eq4d0C_WtSb*Y>>{NGr$ozXB4!s;VL^JjFMWOOIh%(@vDqFJ;LhhsrKG^(+D z(whUwH6eh9lYxqik`09m`w>}NEw4!i^+NCRd!PP?@=cKMZqb;q9ptNhh z)OGHgmR5*5qdjM&oo(Uwqk64SlM_CBTZ;EaXM6=;5 zSHZN_QE1;lXlJX`)<4p{bxu*y%uwf5xWP#Kw?m+kKHNgDSTTFf+^3$N_sskSWmRBt z)$;0SMG-x6x--;w8E!bpoILPSxTqA%B{YqDcA%J!pGcw}WX+~@muG{67aVqemTZCL zM5A(e@lEN!^RjTRX_ny{bwbaf44gwrok%)84!1}?Us_!*2aJ4zD?;@KRUhpJO)J4A zRWTLqjC%$eE8*p50%^g%&%#pDx?bsgrE6{H+C#Ykdai$U;JNCcWo1jnEpv8vZLcS2 zE(nkXcR(By^@2ly7;LnA+yd(s?9to;8l!OA2p_*F+=w{1!BxhGYZn+9aDRdB6hQg6 zhYqxloa_TYN$0l$Nd7e1D{{2CgfunP~I&ACTX##3l_KfqD3qyVgIkNPzR zT8XfLR;QR&q(gn}qepwAI8m1@+IsqZ5Ryv{7*~wQJRm&saDbt@-Kw}%@K}*K4kejukl2$z5^Q&oKmYvNprumV zZMN@~pm|n+oW;!t1<_!$qw`NR2yi=R@3Y^30Lfo=zu~%yH|u;jm+sRgys+aZ)w~z- zQ}la4etg6R+^0K=pEm4<+!aF6G%XY<%RF-C$Vm70(B56cR}LY^+}Re|aZ2jwf&2)( zUI|xFkUIKC4<1uhPIo56jbciXQ9yI}gONi!l#K_I;UoPDbmDA{4NM8pqpURmL>}SD zpDs!7?N9>c3SN_KcDu*x7OW^2OB|j4Z_#Se&>BAbA)J8;(F!01n5hElX$pXC z5A8atR^A7&@ro)kV@h&GywP6(s=QzmygZox&_Au&h~~wXr#ke-wW;jprD|@l#g>MXmS%pqt(iI>{cuH zD4L+02B3fJ91TRwfctQP`nR} z9WEA(%U(?i9*;{-5#)nIc2aq%M3@=U1VMDu%+JVZ1JO zF}EC=P?+X_4K$*|ar|3CkCS&&Fg)WcEa@xE^c80QCpP12gQde6G)!#KMGS>_VQ*Ok z1JwA0uv;#m~?Eu{fFB(e$Nm;!ympERelwko?V +``` + +### 登录并自动检测、写入数据库 +```bash +python3 {baseDir}/scripts/main.py login +``` + +### 删除账号(同时删库里的记录与 profile 用户数据目录) +```bash +python3 {baseDir}/scripts/main.py delete id +python3 {baseDir}/scripts/main.py delete platform <平台> +python3 {baseDir}/scripts/main.py delete platform <平台> <手机号> +``` + +### 列出某平台所有账号(platform 可为中文名、all 或 全部) +```bash +python3 {baseDir}/scripts/main.py list all +python3 {baseDir}/scripts/main.py list 搜狐号 \ No newline at end of file diff --git a/account-manager/release.ps1 b/account-manager/release.ps1 new file mode 100644 index 0000000..639081a --- /dev/null +++ b/account-manager/release.ps1 @@ -0,0 +1,23 @@ +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) + +if (-not (Test-Path $sharedScript)) { + throw "Shared release script not found: $sharedScript" +} + +& $sharedScript @PSBoundParameters +exit $LASTEXITCODE diff --git a/account-manager/scripts/main.py b/account-manager/scripts/main.py new file mode 100644 index 0000000..adc3760 --- /dev/null +++ b/account-manager/scripts/main.py @@ -0,0 +1,1498 @@ +import sys +import json +import logging +import os +import re +import sqlite3 +from logging.handlers import TimedRotatingFileHandler +from urllib.parse import urlparse +import shutil +import subprocess +import tempfile +import time +from datetime import datetime +from typing import Optional + +# Windows GBK 编码兼容修复 +if sys.platform == "win32": + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SKILL_SLUG = "account-manager" +# 与其它 OpenClaw 技能对齐:openclaw.skill. +LOG_LOGGER_NAME = "openclaw.skill.account_manager" + +# --------------------------------------------------------------------------- +# 本地 CLI 调试:直接运行 python main.py 时,若未设置 JIANGCHANG_*,可自动注入下面默认值。 +# - 仅当 __main__ 里调用了 _apply_cli_local_dev_env() 时生效;被其它脚本 import 不会改 os.environ。 +# - 不会覆盖宿主/终端已设置的 JIANGCHANG_DATA_ROOT、JIANGCHANG_USER_ID。 +# - 开启方式(二选一): +# 1) 将 _ACCOUNT_MANAGER_CLI_LOCAL_DEV 改为 True; +# 2) 或设置环境变量 JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1(无需改代码)。 +# 合并/发版前请关闭开关,避免把个人机器路径带进生产制品。 +# --------------------------------------------------------------------------- +_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True +_ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT = r"D:\jiangchang-data" +_ACCOUNT_MANAGER_CLI_LOCAL_USER_ID = "10032" + + +def _apply_cli_local_dev_env() -> None: + enabled = _ACCOUNT_MANAGER_CLI_LOCAL_DEV + if not enabled: + v = (os.getenv("JIANGCHANG_ACCOUNT_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"] = _ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT.strip() + if not (os.getenv("JIANGCHANG_USER_ID") or "").strip(): + os.environ["JIANGCHANG_USER_ID"] = _ACCOUNT_MANAGER_CLI_LOCAL_USER_ID.strip() + + +# SQLite 无独立 DATETIME 类型:时间统一存 INTEGER Unix 秒(UTC),查询/JSON 再转 ISO8601。 +# 下列 DDL 含注释,会原样写入 sqlite_master,便于 Navicat / DBeaver 等查看建表语句。 +ACCOUNTS_TABLE_SQL = """ +/* 多平台账号表:与本地 Chromium/Edge 用户数据目录(profile)绑定,供发布类技能读取登录态 */ +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增) + name TEXT NOT NULL, -- 展示名称,如「搜狐1号」 + platform TEXT NOT NULL, -- 平台标识,与内置 PLATFORMS 键一致,如 sohu + phone TEXT, -- 可选绑定手机号 + profile_dir TEXT, -- Playwright 用户数据目录(绝对路径);默认可读结构 profiles/<平台展示名>/<手机号>/ + url TEXT, -- 平台入口或登录页 URL + login_status INTEGER NOT NULL DEFAULT 0, -- 是否已登录:0 否 1 是(由脚本校验后写入) + last_login_at INTEGER, -- 最近一次登录成功时间,Unix 秒 UTC;未登录过为 NULL + extra_json TEXT, -- 扩展字段 JSON + created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC + updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC +); +""" + + +def _now_unix() -> int: + return int(time.time()) + + +def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]: + """对外 JSON:本地时区 ISO8601 字符串,便于人读。""" + if ts is None: + return None + try: + return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") + except (ValueError, OSError, OverflowError): + return None + + +# 平台唯一配置表:只改这里即可。键 = 入库的 platform;label = 帮助/提示里的展示名; +# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label; +# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。 +# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。 +# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS:通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。 +# - login_logged_out_selectors:按平台追加;误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。 +PLATFORMS = { + "sohu": { + "url": "https://mp.sohu.com", + "label": "搜狐号", + "prefix": "搜狐", + "aliases": ["搜狐"], + }, + "toutiao": { + "url": "https://mp.toutiao.com/", + "label": "头条号", + "prefix": "头条", + "aliases": ["头条"], + }, + "zhihu": { + "url": "https://www.zhihu.com", + "label": "知乎", + "aliases": ["知乎号"], + }, + "wechat": { + "url": "https://mp.weixin.qq.com", + "label": "微信公众号", + "prefix": "微信", + "aliases": ["公众号", "微信"], + }, + "kimi": { + "url": "https://kimi.moonshot.cn", + "label": "Kimi", + "aliases": ["月之暗面"], + }, + "deepseek": { + "url": "https://chat.deepseek.com", + "label": "DeepSeek", + }, + "doubao": { + "url": "https://www.doubao.com", + "label": "豆包", + }, + "qianwen": { + "url": "https://tongyi.aliyun.com", + "label": "通义千问", + "prefix": "通义", + "aliases": ["通义", "千问"], + }, + "yiyan": { + "url": "https://yiyan.baidu.com", + "label": "文心一言", + "prefix": "文心", + "aliases": ["文心", "一言"], + }, + "yuanbao": { + "url": "https://yuanbao.tencent.com", + "label": "腾讯元宝", + "prefix": "元宝", + "aliases": ["元宝"], + }, +} + +# 未登录页共性:主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等,Playwright :has-text 可覆盖多数站点。 +_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = ( + 'button:has-text("登录")', + 'role=button[name="登录"]', + 'a:has-text("登录")', + 'h4:has-text("登录")', + 'span.semi-button-content:has-text("登录")', +) + + +def _build_platform_derived(): + urls = {} + name_prefix = {} + primary_cn = {} + alias_to_key = {} + + def _register_alias(alias: str, key: str) -> None: + if not alias or not str(alias).strip(): + return + a = str(alias).strip() + if a not in alias_to_key: + alias_to_key[a] = key + lo = a.lower() + if lo not in alias_to_key: + alias_to_key[lo] = key + + for key, spec in PLATFORMS.items(): + urls[key] = spec["url"] + primary_cn[key] = spec["label"] + name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key + _register_alias(key, key) + _register_alias(spec["label"], key) + for a in spec.get("aliases") or []: + _register_alias(a, key) + + return urls, name_prefix, primary_cn, alias_to_key + + +PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived() + + +def resolve_platform_key(name: str): + """将用户输入解析为内部 platform 键;无法识别返回 None。""" + if name is None: + return None + s = str(name).strip() + if not s: + return None + sl = s.lower() + if sl in PLATFORM_URLS: + return sl + return _PLATFORM_ALIAS_TO_KEY.get(s) + + +def _normalize_phone_digits(phone: str) -> str: + """从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。""" + d = re.sub(r"\D", "", (phone or "").strip()) + if d.startswith("86") and len(d) >= 13: + d = d[2:] + return d + + +def _is_valid_cn_mobile11(digits: str) -> bool: + """中国大陆 11 位手机号:1 开头,第二位 3–9,共 11 位数字。""" + return bool(re.fullmatch(r"1[3-9]\d{9}", digits or "")) + + +def _platform_list_cn_for_help() -> str: + """帮助文案:一行中文展示名(不重复内部键)。""" + parts = [] + for k in sorted(PLATFORM_URLS.keys()): + parts.append(_PLATFORM_PRIMARY_CN.get(k, k)) + return "、".join(parts) + + +def get_data_root(): + env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() + if env: + return env + if sys.platform == "win32": + return r"D:\jiangchang-data" + return os.path.join(os.path.expanduser("~"), ".jiangchang-data") + + +def get_user_id(): + uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() + return uid or "_anon" + + +def get_skill_data_dir(): + path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) + os.makedirs(path, exist_ok=True) + return path + + +def get_skill_logs_dir() -> str: + """{DATA_ROOT}/{USER_ID}/account-manager/logs/""" + path = os.path.join(get_skill_data_dir(), "logs") + os.makedirs(path, exist_ok=True) + return path + + +def get_skill_log_file_path() -> str: + """主日志文件绝对路径(与 get_skill_logger 写入文件一致)。""" + return _skill_log_file_path() + + +def _skill_log_file_path() -> str: + """主日志文件路径;可由 JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖为绝对路径。""" + override = (os.getenv("JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE") or "").strip() + if override: + parent = os.path.dirname(os.path.abspath(override)) + if parent: + os.makedirs(parent, exist_ok=True) + return os.path.abspath(override) + return os.path.join(get_skill_logs_dir(), f"{SKILL_SLUG}.log") + + +def _log_level_from_env() -> int: + v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper() + return getattr(logging, v, None) or logging.INFO + + +def get_skill_logger() -> logging.Logger: + """ + 技能级日志:单文件按日切分(TimedRotatingFileHandler midnight),UTF-8。 + 环境变量:JIANGCHANG_LOG_LEVEL(默认 INFO)、JIANGCHANG_LOG_TO_STDERR=1 时 WARNING+ 同步 stderr、 + JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖日志文件路径。 + """ + log = logging.getLogger(LOG_LOGGER_NAME) + if log.handlers: + return log + log.setLevel(_log_level_from_env()) + path = _skill_log_file_path() + fh = TimedRotatingFileHandler( + path, + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8", + delay=True, + ) + fh.setFormatter( + logging.Formatter( + "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) + ) + log.addHandler(fh) + 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(fh.formatter) + log.addHandler(sh) + log.propagate = False + return log + + +def get_db_path(): + return os.path.join(get_skill_data_dir(), "account-manager.db") + + +def _runtime_paths_debug_text(): + """供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。""" + env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)" + env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)" + return ( + "[account-manager] " + f"JIANGCHANG_DATA_ROOT={env_root} | " + f"JIANGCHANG_USER_ID={env_uid} | " + f"实际数据根={get_data_root()} | " + f"实际用户目录={get_user_id()} | " + f"数据库文件={get_db_path()}" + ) + + +def _maybe_print_paths_debug_cli(): + """设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。""" + v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower() + if v in ("1", "true", "yes", "on"): + print(_runtime_paths_debug_text(), file=sys.stderr) + + +_WIN_FS_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +_WIN_RESERVED_NAMES = frozenset( + ["CON", "PRN", "AUX", "NUL"] + + [f"COM{i}" for i in range(1, 10)] + + [f"LPT{i}" for i in range(1, 10)] +) + + +def _fs_safe_segment(name: str, fallback: str) -> str: + """单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback。""" + t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip()) + t = t.rstrip(" .") + if not t: + t = fallback + if t.upper() in _WIN_RESERVED_NAMES: + t = f"_{t}" + return t + + +def get_default_profile_dir( + platform_key: str, phone: Optional[str], account_id: Optional[int] = None +) -> str: + """ + 默认可读路径:profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。 + 无手机号时退化为 profiles/<平台展示名>/no_phone_/。 + """ + label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account") + label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform")) + ph = (phone or "").strip() + if ph: + phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone") + else: + phone_seg = _fs_safe_segment( + "", f"no_phone_{account_id}" if account_id is not None else "no_phone" + ) + path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg) + os.makedirs(path, exist_ok=True) + return path + + +def get_conn(): + return sqlite3.connect(get_db_path()) + + +def init_db(): + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'") + if not cur.fetchone(): + cur.executescript(ACCOUNTS_TABLE_SQL) + conn.commit() + finally: + conn.close() + + +def _normalize_account_id(account_id): + if account_id is None: + return None + s = str(account_id).strip() + if s.isdigit(): + return int(s) + return s + + +def get_account_by_id(account_id): + init_db() + aid = _normalize_account_id(account_id) + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, + created_at, updated_at + FROM accounts WHERE id = ? + """, + (aid,), + ) + row = cur.fetchone() + if not row: + return None + acc = { + "id": row[0], + "name": row[1], + "platform": row[2], + "phone": row[3] or "", + "profile_dir": (row[4] or "").strip(), + "url": row[5] or PLATFORM_URLS.get(row[2], ""), + "login_status": int(row[6] or 0), + "last_login_at": _unix_to_iso_local(row[7]), + "created_at": _unix_to_iso_local(row[9]), + "updated_at": _unix_to_iso_local(row[10]), + } + if row[8]: + try: + extra = json.loads(row[8]) + if isinstance(extra, dict): + acc.update(extra) + except Exception: + pass + return acc + finally: + conn.close() + + +def _default_name_for_platform(platform: str, index: int) -> str: + cn = _PLATFORM_NAME_CN.get(platform) + if cn: + return f"{cn}{index}号" + return f"{platform}_{index}" + + +# list 表头:与 accounts 表列顺序、列名一致(便于对照库结构) +_LIST_TABLE_COLUMNS = ( + "id", + "name", + "platform", + "phone", + "profile_dir", + "url", + "login_status", + "last_login_at", + "extra_json", + "created_at", + "updated_at", +) + + +def _list_row_to_cells(row: tuple) -> list: + """将 SELECT 整行转为展示用字符串列表(与 _LIST_TABLE_COLUMNS 顺序一致)。""" + rid, name, plat, phone, pdir, url, lstat, lla, exj, cat, uat = row + return [ + str(rid) if rid is not None else "", + name if name is not None else "", + plat if plat is not None else "", + phone if phone is not None else "", + pdir if pdir is not None else "", + url if url is not None else "", + str(int(lstat)) if lstat is not None else "", + str(int(lla)) if lla is not None else "", + (exj or "").replace("\n", "\\n").replace("\r", ""), + str(int(cat)) if cat is not None else "", + str(int(uat)) if uat is not None else "", + ] + + +def _print_accounts_table(rows: list) -> None: + """表头 + 分隔线 + 每账号一行;列宽按内容对齐(终端等宽字体)。""" + headers = list(_LIST_TABLE_COLUMNS) + body = [_list_row_to_cells(r) for r in rows] + n = len(headers) + widths = [len(headers[j]) for j in range(n)] + for cells in body: + for j in range(n): + widths[j] = max(widths[j], len(cells[j])) + sep = " | " + + def fmt(cells): + return sep.join(cells[j].ljust(widths[j]) for j in range(n)) + + print(fmt(headers)) + print(sep.join("-" * widths[j] for j in range(n))) + for cells in body: + print(fmt(cells)) + + +def cmd_list(platform="all", limit: int = 10): + get_skill_logger().info("list filter=%r", platform) + init_db() + raw = (platform or "all").strip() + if not raw or raw.lower() == "all" or raw == "全部": + key = "all" + else: + key = resolve_platform_key(raw) + if not key: + get_skill_logger().warning("list_invalid_platform raw=%r", raw) + print(f"ERROR:INVALID_PLATFORM_LIST 无法识别的平台「{raw}」") + print("支持:" + _platform_list_cn_for_help()) + return + + if limit <= 0: + limit = 10 + + conn = get_conn() + try: + cur = conn.cursor() + sql = ( + "SELECT id, name, platform, phone, profile_dir, url, login_status, " + "last_login_at, extra_json, created_at, updated_at FROM accounts " + ) + if key == "all": + cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),)) + else: + cur.execute( + sql + "WHERE platform = ? ORDER BY created_at DESC, id DESC LIMIT ?", + (key, int(limit)), + ) + rows = cur.fetchall() + finally: + conn.close() + + found = bool(rows) + if found: + get_skill_logger().info("list_ok rows=%s key=%s", len(rows), key) + sep_line = "_" * 39 + for idx, row in enumerate(rows): + ( + aid, + name, + plat, + phone, + profile_dir, + url, + login_status, + last_login_at, + extra_json, + created_at, + updated_at, + ) = row + print(f"id:{aid}") + print(f"name:{name or ''}") + print(f"platform:{plat or ''}") + print(f"phone:{phone or ''}") + print(f"profile_dir:{profile_dir or ''}") + print(f"url:{url or ''}") + print(f"login_status:{int(login_status) if login_status is not None else ''}") + print(f"last_login_at:{int(last_login_at) if last_login_at is not None else ''}") + print(f"extra_json:{extra_json or ''}") + print(f"created_at:{int(created_at) if created_at is not None else ''}") + print(f"updated_at:{int(updated_at) if updated_at is not None else ''}") + if idx != len(rows) - 1: + print(sep_line) + print() + + if not found: + print("ERROR:NO_ACCOUNTS_FOUND") + print(_runtime_paths_debug_text(), file=sys.stderr) + + +def cmd_get(account_id): + get_skill_logger().info("get account_id=%r", account_id) + acc = get_account_by_id(account_id) + if acc: + print(json.dumps(acc, ensure_ascii=False)) + return + get_skill_logger().warning("get_not_found account_id=%r", account_id) + print("ERROR:ACCOUNT_NOT_FOUND") + print(_runtime_paths_debug_text(), file=sys.stderr) + + +def cmd_pick_logged_in(platform_input: str): + """ + 机器可读跨技能接口:查询指定平台下「已登录」的一条账号(login_status=1,按 last_login_at 优先)。 + 成功:stdout 仅输出一行 JSON,结构与 get 子命令一致。 + 失败:stdout 首行以 ERROR: 开头(由调用方判断,勿解析为 JSON)。 + """ + get_skill_logger().info("pick_logged_in platform_input=%r", platform_input) + key = resolve_platform_key((platform_input or "").strip()) + if not key: + print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") + print("支持:" + _platform_list_cn_for_help()) + return + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT id FROM accounts + WHERE platform = ? AND login_status = 1 + ORDER BY (last_login_at IS NULL), last_login_at DESC + LIMIT 1 + """, + (key,), + ) + row = cur.fetchone() + finally: + conn.close() + + if not row: + print("ERROR:NO_LOGGED_IN_ACCOUNT 该平台暂无已登录账号(login_status=1)。") + print("请先 list 查看账号 id,再执行:python main.py login ") + return + + acc = get_account_by_id(row[0]) + if not acc: + print("ERROR:ACCOUNT_NOT_FOUND") + print(_runtime_paths_debug_text(), file=sys.stderr) + return + print(json.dumps(acc, ensure_ascii=False)) + + +def cmd_add(platform_input: str, phone: str): + """添加账号:platform 可为中文展示名或英文键;手机号必填;同平台下同手机号不可重复。""" + log = get_skill_logger() + log.info("add_attempt platform_input=%r", platform_input) + init_db() + key = resolve_platform_key((platform_input or "").strip()) + if not key: + log.warning("add_invalid_platform input=%r", platform_input) + print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") + print("支持:" + _platform_list_cn_for_help()) + return + + phone_raw = (phone or "").strip() + if not phone_raw: + log.warning("add_phone_missing") + print("ERROR:PHONE_REQUIRED 手机号为必填。") + return + phone_norm = _normalize_phone_digits(phone_raw) + if not _is_valid_cn_mobile11(phone_norm): + log.warning("add_phone_invalid digits_len=%s", len(phone_norm or "")) + print( + "ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码," + "以 1 开头、第二位为 3~9(示例 13800138000)。" + ) + return + + url = PLATFORM_URLS[key] + now = _now_unix() + phone_store = phone_norm + + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id FROM accounts WHERE platform = ? AND phone = ?", + (key, phone_store), + ) + if cur.fetchone(): + log.warning("add_duplicate platform=%s phone_suffix=%s", key, phone_store[-4:]) + print( + f"ERROR:DUPLICATE_PHONE_PLATFORM 该平台下已存在手机号 {phone_store},请勿重复添加。" + ) + return + + cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (key,)) + next_idx = cur.fetchone()[0] + 1 + name = _default_name_for_platform(key, next_idx) + profile_dir = get_default_profile_dir(key, phone_store, None) + cur.execute( + """ + INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?) + """, + (name, key, phone_store, profile_dir, url, now, now), + ) + new_id = cur.lastrowid + conn.commit() + finally: + conn.close() + + print( + f"✅ 已保存账号:ID {new_id} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_store}" + ) + print(f"ℹ️ 登录请执行:python main.py login {new_id}") + log.info( + "add_success id=%s platform=%s profile_dir=%s", + new_id, + key, + profile_dir, + ) + + +def _remove_profile_dir(path: str) -> None: + p = (path or "").strip() + if not p or not os.path.isdir(p): + return + try: + shutil.rmtree(p) + except OSError as e: + print(f"⚠️ 未能删除用户数据目录(可手工删):{p}\n {e}", file=sys.stderr) + + +def cmd_delete_by_id(account_id) -> None: + log = get_skill_logger() + log.info("delete_by_id account_id=%r", account_id) + init_db() + aid = _normalize_account_id(account_id) + if aid is None or (isinstance(aid, str) and not str(aid).isdigit()): + log.warning("delete_invalid_id") + print("ERROR:DELETE_INVALID_ID 账号 id 须为正整数。") + return + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, name, platform, phone, profile_dir FROM accounts WHERE id = ?", + (int(aid),), + ) + row = cur.fetchone() + if not row: + log.warning("delete_by_id_not_found id=%s", aid) + print("ERROR:ACCOUNT_NOT_FOUND") + print(_runtime_paths_debug_text(), file=sys.stderr) + return + rid, name, plat, phone, profile_dir = row + cur.execute("DELETE FROM accounts WHERE id = ?", (rid,)) + conn.commit() + finally: + conn.close() + _remove_profile_dir(profile_dir) + print( + f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(plat, plat)} | 手机 {phone or '(无)'}" + ) + log.info("delete_by_id_done id=%s platform=%s", rid, plat) + + +def cmd_delete_by_platform(platform_input: str) -> None: + log = get_skill_logger() + log.info("delete_by_platform platform_input=%r", platform_input) + init_db() + key = resolve_platform_key((platform_input or "").strip()) + if not key: + log.warning("delete_by_platform_invalid_platform") + print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") + print("支持:" + _platform_list_cn_for_help()) + return + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, profile_dir FROM accounts WHERE platform = ?", + (key,), + ) + rows = cur.fetchall() + if not rows: + log.warning("delete_by_platform_empty platform=%s", key) + print("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。") + return + cur.execute("DELETE FROM accounts WHERE platform = ?", (key,)) + conn.commit() + finally: + conn.close() + for _rid, pdir in rows: + _remove_profile_dir(pdir) + print( + f"✅ 已删除 {_PLATFORM_PRIMARY_CN.get(key, key)} 下共 {len(rows)} 条账号及对应用户数据目录。" + ) + log.info("delete_by_platform_done platform=%s count=%s", key, len(rows)) + + +def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None: + log = get_skill_logger() + log.info("delete_by_platform_phone platform_input=%r", platform_input) + init_db() + key = resolve_platform_key((platform_input or "").strip()) + if not key: + print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") + print("支持:" + _platform_list_cn_for_help()) + return + phone_raw = (phone or "").strip() + if not phone_raw: + print("ERROR:PHONE_REQUIRED 手机号为必填。") + return + phone_norm = _normalize_phone_digits(phone_raw) + if not _is_valid_cn_mobile11(phone_norm): + print( + "ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码," + "以 1 开头、第二位为 3~9。" + ) + return + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, name, profile_dir FROM accounts WHERE platform = ? AND phone = ?", + (key, phone_norm), + ) + row = cur.fetchone() + if not row: + print("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。") + return + rid, name, profile_dir = row + cur.execute( + "DELETE FROM accounts WHERE platform = ? AND phone = ?", + (key, phone_norm), + ) + conn.commit() + finally: + conn.close() + _remove_profile_dir(profile_dir) + print( + f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_norm}" + ) + log.info("delete_by_platform_phone_done id=%s platform=%s", rid, key) + + +def _win_find_exe(candidates): + for p in candidates: + if p and os.path.isfile(p): + return p + return None + + +def resolve_chromium_channel(): + """ + Playwright channel: 'chrome' | 'msedge' | None + Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。 + """ + if sys.platform != "win32": + return "chrome" + + pf = os.environ.get("ProgramFiles", r"C:\Program Files") + pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + local = os.environ.get("LocalAppData", "") + + chrome = _win_find_exe( + [ + os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "", + ] + ) + if chrome: + return "chrome" + + edge = _win_find_exe( + [ + os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"), + os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"), + os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "", + ] + ) + if edge: + return "msedge" + + return None + + +def _print_browser_install_hint(): + print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装后再登录:") + print(" • Chrome: https://www.google.com/chrome/") + print(" • Edge: https://www.microsoft.com/zh-cn/edge") + + +def _login_timeout_seconds(): + try: + return max(60, int((os.getenv("JIANGCHANG_LOGIN_TIMEOUT_SECONDS") or "300").strip())) + except ValueError: + return 300 + + +def _login_poll_interval_seconds(): + """轮询间隔,默认 1.5s。""" + try: + v = float((os.getenv("JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS") or "1.5").strip()) + return max(0.5, min(v, 10.0)) + except ValueError: + return 1.5 + + +def _login_dom_grace_seconds() -> float: + """goto 后等待再开始 DOM 判定,减轻跳转中途误判。默认 4s,JIANGCHANG_LOGIN_DOM_GRACE_SECONDS。""" + try: + v = float((os.getenv("JIANGCHANG_LOGIN_DOM_GRACE_SECONDS") or "4").strip()) + return max(0.0, min(v, 60.0)) + except ValueError: + return 4.0 + + +def _login_out_bundle_for(platform_key: str) -> dict: + """传给子进程:anchor + 未登录可见的选择器列表。""" + spec = PLATFORMS.get(platform_key, {}) if platform_key else {} + anchor = (urlparse(PLATFORM_URLS.get(platform_key, "") or "").netloc or "").lower() + spec_lo_dom = [ + str(s).strip() + for s in (spec.get("login_logged_out_selectors") or []) + if s is not None and str(s).strip() + ] + if bool(spec.get("login_skip_generic_logged_out_dom")): + logged_out_dom_selectors = list(spec_lo_dom) + else: + logged_out_dom_selectors = list(_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS) + spec_lo_dom + return { + "logged_out_dom_selectors": logged_out_dom_selectors, + "anchor_host": anchor, + } + + +def cmd_login(account_id): + log = get_skill_logger() + log.info("login_command account_id=%r", account_id) + target = get_account_by_id(account_id) + if not target: + log.warning("login_aborted account_not_found account_id=%r", account_id) + print("ERROR:ACCOUNT_NOT_FOUND") + return + + channel = resolve_chromium_channel() + if not channel: + log.warning("login_aborted no_chromium_channel") + _print_browser_install_hint() + return + + try: + import playwright # noqa: F401 + except ImportError: + log.error("login_aborted playwright_missing") + print("ERROR:需要 playwright:pip install playwright && playwright install chromium") + return + + profile_dir = (target.get("profile_dir") or "").strip() + if not profile_dir: + log.warning("login_aborted profile_dir_empty account_id=%s", target.get("id")) + print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。") + return + os.makedirs(profile_dir, exist_ok=True) + url = (target.get("url") or "").strip() or PLATFORM_URLS.get(target["platform"], "https://www.google.com") + platform = (target.get("platform") or "").strip().lower() + timeout_sec = _login_timeout_seconds() + poll_sec = _login_poll_interval_seconds() + dom_grace_sec = _login_dom_grace_seconds() + + browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" + print(f"正在为账号 [{target['name']}] 打开 {browser_name} …") + print(f"访问地址:{url}") + print( + "请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;" + "成功后自动关闭窗口并写入状态(无需手动关浏览器)。" + ) + print( + f"页面加载后先等待约 {dom_grace_sec:g} 秒再开始检测(减轻跳转误判,可用 JIANGCHANG_LOGIN_DOM_GRACE_SECONDS 调整)。" + ) + print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。") + + # 子进程:login_detect_bundle + 多 tab DOM;日志与主进程同一文件(追加)。 + log_file = _skill_log_file_path() + log.info( + "login_browser_start account_id=%s platform=%s channel=%s timeout_sec=%s poll_sec=%s " + "dom_grace_sec=%s profile_dir=%s start_url=%s log_file=%s", + target.get("id"), + platform, + channel, + timeout_sec, + poll_sec, + dom_grace_sec, + profile_dir, + url, + log_file, + ) + runner = r"""import json, logging, sys, time +from urllib.parse import urlparse +from playwright.sync_api import sync_playwright + +def page_location_href(p): + # Prefer location.href over page.url for SPA (e.g. Sohu shell path lag). + try: + href = p.evaluate("() => location.href") + if href and str(href).strip(): + return str(href).strip() + except Exception: + pass + try: + return (p.url or "").strip() + except Exception: + return "" + +def host_matches_anchor(netloc, anchor): + h = (netloc or "").lower().strip() + a = (anchor or "").lower().strip() + if not a: + return True + if not h: + return False + return h == a or h.endswith("." + a) or a.endswith("." + h) + +def loc_first_visible(pg, selectors): + for sel in selectors: + s = str(sel).strip() + if not s: + continue + try: + loc = pg.locator(s).first + if loc.is_visible(timeout=500): + return True, s + except Exception: + pass + return False, None + +def list_pages_on_anchor(ctx, main_page, bundle): + anchor = (bundle.get("anchor_host") or "").strip().lower() + seen_ids = set() + pages = [] + for pg in list(ctx.pages or []): + try: + i = id(pg) + if i not in seen_ids: + seen_ids.add(i) + pages.append(pg) + except Exception: + pages.append(pg) + if main_page is not None: + try: + i = id(main_page) + if i not in seen_ids: + seen_ids.add(i) + pages.append(main_page) + except Exception: + pages.append(main_page) + out = [] + for pg in pages: + try: + href = page_location_href(pg) + except Exception: + continue + if not href or str(href).lower().startswith("about:"): + continue + try: + host = urlparse(href).netloc.lower() + except Exception: + continue + if anchor and not host_matches_anchor(host, anchor): + continue + out.append(pg) + return out + +def evaluate_logged_out_dom(ctx, main_page, bundle): + selectors = [ + str(x).strip() + for x in (bundle.get("logged_out_dom_selectors") or []) + if x is not None and str(x).strip() + ] + if not selectors: + return True, "no_logged_out_dom_selectors_configured" + for pg in list_pages_on_anchor(ctx, main_page, bundle): + try: + href = page_location_href(pg) + except Exception: + href = "" + hit, which = loc_first_visible(pg, selectors) + if hit: + return True, "logged_out_dom=%r url=%r" % (which, href) + return False, "" + +with open(sys.argv[1], encoding="utf-8") as f: + c = json.load(f) +bundle = c.get("login_detect_bundle") or {} +poll = float(c.get("poll_interval", 1.5)) +try: + dom_grace = float(c.get("dom_grace_sec", 4.0)) +except (TypeError, ValueError): + dom_grace = 4.0 +if dom_grace < 0: + dom_grace = 0.0 +t0 = time.time() +deadline = t0 + float(c["timeout_sec"]) +interactive_ok = False +result_path = c.get("result_path") or "" +log_path = (c.get("log_file") or "").strip() +lg = logging.getLogger("openclaw.skill.account_manager.login_child") +if log_path: + lg.handlers.clear() + lg.setLevel(logging.DEBUG) + _fh = logging.FileHandler(log_path, encoding="utf-8") + _fh.setFormatter( + logging.Formatter( + "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) + ) + lg.addHandler(_fh) + lg.propagate = False +last_eval_detail = "" +with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + user_data_dir=c["profile_dir"], + headless=False, + channel=c["channel"], + no_viewport=True, + args=["--start-maximized"], + ) + try: + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(c["url"], wait_until="domcontentloaded", timeout=60000) + if dom_grace > 0: + time.sleep(dom_grace) + if lg.handlers: + lg.info( + "goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s", + len(ctx.pages or []), + poll, + dom_grace, + ) + while time.time() < deadline: + iter_start = time.time() + try: + still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle) + ok = not still_logged_out + last_eval_detail = detail + if lg.handlers: + parts = [] + for tab in list(ctx.pages or []): + try: + href = page_location_href(tab) + pu = (tab.url or "").strip() + parts.append("href=%r playwright_url=%r" % (href, pu)) + except Exception: + parts.append("(tab_read_error)") + lg.debug( + "poll iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s", + iter_start - t0, + deadline - iter_start, + len(ctx.pages or []), + poll, + still_logged_out, + ok, + detail, + " ; ".join(parts) if parts else "no_tabs", + ) + if ok: + interactive_ok = True + if lg.handlers: + lg.info("login_detected_ok %s", detail) + break + except Exception as ex: + if lg.handlers: + lg.warning("poll_exception err=%s", ex, exc_info=True) + spent = time.time() - iter_start + time.sleep(max(0.0, poll - spent)) + if not interactive_ok: + if lg.handlers: + lg.warning("login_poll_exhausted detail=%s", last_eval_detail) + rem = max(0.0, deadline - time.time()) + if rem > 0: + try: + ctx.wait_for_event("close", timeout=rem * 1000) + except Exception: + pass + else: + if lg.handlers: + lg.info("login_success_closing_browser_immediately") + except Exception as e: + if lg.handlers: + lg.exception("login_runner_fatal err=%s", e) + print(e, file=sys.stderr) + raise + finally: + if result_path: + try: + with open(result_path, "w", encoding="utf-8") as rf: + json.dump({"interactive_ok": interactive_ok}, rf, ensure_ascii=False) + except Exception: + pass + try: + ctx.close() + except Exception: + pass +""" + cfg = { + "channel": channel, + "profile_dir": profile_dir, + "url": url, + "timeout_sec": timeout_sec, + "poll_interval": poll_sec, + "dom_grace_sec": dom_grace_sec, + "login_detect_bundle": _login_out_bundle_for(platform), + "log_file": log_file, + } + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as rf: + result_path = rf.name + cfg["result_path"] = result_path + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as jf: + json.dump(cfg, jf, ensure_ascii=False) + cfg_path = jf.name + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as pf: + pf.write(runner) + py_path = pf.name + proc_rc = None + try: + r = subprocess.run( + [sys.executable, py_path, cfg_path], + timeout=timeout_sec + 180, + ) + proc_rc = r.returncode + if proc_rc != 0: + print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态") + log.warning("login_subprocess_nonzero_return code=%s", proc_rc) + except subprocess.TimeoutExpired as ex: + print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态") + log.warning("login_subprocess_timeout err=%s", ex) + finally: + try: + os.unlink(cfg_path) + except OSError: + pass + try: + os.unlink(py_path) + except OSError: + pass + + interactive_ok = False + try: + with open(result_path, encoding="utf-8") as rf: + interactive_ok = bool(json.load(rf).get("interactive_ok")) + except Exception: + pass + try: + os.unlink(result_path) + except OSError: + pass + + time.sleep(0.5) + ok = interactive_ok + log.info( + "login_finished account_id=%s interactive_ok=%s subprocess_rc=%s marking_db=%s", + target.get("id"), + interactive_ok, + proc_rc, + ok, + ) + _mark_login_status(target["id"], ok) + if ok: + print("✅ 已判定登录成功,状态已写入数据库") + else: + log.warning( + "login_not_detected account_id=%s platform=%s see_log=%s", + target.get("id"), + platform, + log_file, + ) + print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。") + print(f"ℹ️ 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)") + + +def cmd_open(account_id): + """打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库。""" + get_skill_logger().info("open account_id=%r", account_id) + target = get_account_by_id(account_id) + if not target: + get_skill_logger().warning("open_not_found account_id=%r", account_id) + print("ERROR:ACCOUNT_NOT_FOUND") + return + + channel = resolve_chromium_channel() + if not channel: + _print_browser_install_hint() + return + + try: + import playwright # noqa: F401 + except ImportError: + print("ERROR:需要 playwright:pip install playwright && playwright install chromium") + return + + profile_dir = (target.get("profile_dir") or "").strip() + if not profile_dir: + print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。") + return + os.makedirs(profile_dir, exist_ok=True) + url = (target.get("url") or "").strip() or PLATFORM_URLS.get( + target["platform"], "https://www.google.com" + ) + browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" + print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)") + print(f"地址:{url}") + print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。") + print("需要自动检测并写入数据库时,请执行:python main.py login ") + + runner_open = r"""import json, sys +from playwright.sync_api import sync_playwright +with open(sys.argv[1], encoding="utf-8") as f: + c = json.load(f) +with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + user_data_dir=c["profile_dir"], + headless=False, + channel=c["channel"], + no_viewport=True, + args=["--start-maximized"], + ) + try: + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(c["url"], wait_until="domcontentloaded", timeout=60000) + ctx.wait_for_event("close", timeout=86400000) + except Exception as e: + print(e, file=sys.stderr) + raise + finally: + try: + ctx.close() + except Exception: + pass +""" + cfg = {"channel": channel, "profile_dir": profile_dir, "url": url} + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as jf: + json.dump(cfg, jf, ensure_ascii=False) + cfg_path = jf.name + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as pf: + pf.write(runner_open) + py_path = pf.name + try: + subprocess.run([sys.executable, py_path, cfg_path]) + finally: + try: + os.unlink(cfg_path) + except OSError: + pass + try: + os.unlink(py_path) + except OSError: + pass + + +def _mark_login_status(account_id, success: bool): + now = _now_unix() + conn = get_conn() + try: + cur = conn.cursor() + if success: + cur.execute( + "UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?", + (now, now, account_id), + ) + else: + cur.execute( + "UPDATE accounts SET login_status = 0, updated_at = ? WHERE id = ?", + (now, account_id), + ) + conn.commit() + finally: + conn.close() + + +def _cli_print_full_usage() -> None: + """总览(未带子命令或需要完整说明时)。""" + print("用法概览(将 main.py 换为你的路径):") + print(" python main.py add <平台中文名> <手机号>") + print(" python main.py list [平台|all|全部]") + print(" python main.py pick-logged-in <平台> # 跨技能用:输出一行 JSON 或 ERROR") + print(" python main.py get ") + print(" python main.py open ") + print(" python main.py login ") + print(" python main.py delete id ") + print(" python main.py delete platform <平台>") + print(" python main.py delete platform <平台> <手机号>") + print(" python main.py <平台> # 等价于只列该平台") + print(" python main.py <平台> list # 同上") + print() + print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu):") + print(" " + _platform_list_cn_for_help()) + + +def _cli_fail_add() -> None: + print("ERROR:CLI_ADD_MISSING_ARGS") + print() + print("【add 添加账号】参数不足。") + print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)") + print(" 必填:中国大陆 11 位手机号(1 开头,第二位 3~9),同平台下不可重复") + print() + print("示例:") + print(" python main.py add 搜狐号 13800138000") + print(" python main.py add 知乎 13900001111") + print() + print("支持的平台:" + _platform_list_cn_for_help()) + + +def _cli_fail_need_account_id(subcmd: str) -> None: + print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID") + print() + print(f"【{subcmd}】缺少必填参数:账号 id(数据库自增整数,可先 list 查看)。") + print("示例:") + print(f" python main.py {subcmd} 1") + + +def _cli_fail_delete() -> None: + print("ERROR:CLI_DELETE_MISSING_ARGS") + print() + print("【delete 删除账号】参数不足。支持三种方式:") + print(" 按 id:python main.py delete id ") + print(" 按平台(删该平台下全部):python main.py delete platform <平台中文名或英文键>") + print(" 按平台+手机号:python main.py delete platform <平台> <手机号>") + print() + print("示例:") + print(" python main.py delete id 3") + print(" python main.py delete platform 搜狐号") + print(" python main.py delete platform 头条号 18925203701") + print() + print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + _cli_print_full_usage() + sys.exit(1) + + _apply_cli_local_dev_env() + get_skill_logger().info("cli_start argv=%s", sys.argv) + cmd = sys.argv[1] + _maybe_print_paths_debug_cli() + + plat_from_first = resolve_platform_key(cmd) + + if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first: + cmd_list(plat_from_first) + elif plat_from_first and len(sys.argv) == 2: + cmd_list(plat_from_first) + elif cmd == "add": + if len(sys.argv) < 4: + _cli_fail_add() + sys.exit(1) + cmd_add(sys.argv[2], sys.argv[3]) + elif cmd == "list": + cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all") + elif cmd == "get": + if len(sys.argv) < 3: + _cli_fail_need_account_id("get") + sys.exit(1) + cmd_get(sys.argv[2]) + elif cmd == "pick-logged-in": + if len(sys.argv) < 3: + print("ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS") + print("用法:python main.py pick-logged-in <平台中文名或英文键>") + print("说明:供 llm-manager 等技能查询该平台已登录账号;成功时 stdout 仅一行 JSON。") + sys.exit(1) + cmd_pick_logged_in(sys.argv[2]) + elif cmd == "open": + if len(sys.argv) < 3: + _cli_fail_need_account_id("open") + sys.exit(1) + cmd_open(sys.argv[2]) + elif cmd == "login": + if len(sys.argv) < 3: + _cli_fail_need_account_id("login") + sys.exit(1) + cmd_login(sys.argv[2]) + elif cmd == "delete": + if len(sys.argv) < 4: + _cli_fail_delete() + sys.exit(1) + mode = (sys.argv[2] or "").strip().lower() + if mode == "id": + cmd_delete_by_id(sys.argv[3]) + elif mode == "platform": + if len(sys.argv) >= 5: + cmd_delete_by_platform_phone(sys.argv[3], sys.argv[4]) + else: + cmd_delete_by_platform(sys.argv[3]) + else: + print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」,请使用 id 或 platform。") + _cli_fail_delete() + sys.exit(1) + else: + print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法。") + print() + _cli_print_full_usage() + print() + print("说明:list 的筛选可为 all/全部,或上面列出的任一平台中文名。") + sys.exit(1) diff --git a/api-key-vault/.github/workflows/release_skill.yaml b/api-key-vault/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..21617a6 --- /dev/null +++ b/api-key-vault/.github/workflows/release_skill.yaml @@ -0,0 +1,11 @@ +name: 技能自动化发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/api-key-vault/SKILL.md b/api-key-vault/SKILL.md new file mode 100644 index 0000000..c80c3b9 --- /dev/null +++ b/api-key-vault/SKILL.md @@ -0,0 +1,54 @@ +--- +name: API Key管理 +description: API Key统一管理工具。用于存储、读取、更新、删除各种第三方平台的API Key。当需要获取任何平台的API Key时调用本Skill。 +version: 1.0.0 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: api-key-vault + emoji: "🔐" + category: "通用" +allowed-tools: + - bash +--- + +# API Key 管理vault + +## 用途 + +统一管理所有第三方平台的API Key,供其他Skill调用。 +Key存储在本地 `.env` 文件中,不上传任何服务器。 + +## 使用方式 + +### 读取一个Key +```bash +python3 {baseDir}/scripts/vault.py get 17track +``` + +### 写入/更新一个Key +```bash +python3 {baseDir}/scripts/vault.py set 17track YOUR_API_KEY_HERE +``` + +### 列出所有已存储的Key名称 +```bash +python3 {baseDir}/scripts/vault.py list +``` + +### 删除一个Key +```bash +python3 {baseDir}/scripts/vault.py delete 17track +``` + +## 返回格式 + +- get成功:直接返回Key的值,无多余内容 +- get失败:返回 `ERROR:KEY_NOT_FOUND` +- set/delete/list:返回操作结果说明 + +## 注意事项 + +- Key名称统一用小写+连字符,例如 `17track`、`ups-api`、`fedex-oauth` +- `.env` 文件位于 `api-key-vault/` 根目录,不要手动编辑格式 +- 任何Skill需要Key时,调用本Skill的get命令获取,不要硬编码 \ No newline at end of file diff --git a/api-key-vault/release.ps1 b/api-key-vault/release.ps1 new file mode 100644 index 0000000..0e7c8c5 --- /dev/null +++ b/api-key-vault/release.ps1 @@ -0,0 +1,220 @@ +<# +.SYNOPSIS + One-command release script for skill repos. + +.DESCRIPTION + - Optional auto-commit + - Push current branch + - Auto-increment semantic tag (vX.Y.Z) + - Create & push tag + - Fail fast on unsafe states + +.EXAMPLES + # Safe mode (recommended): requires clean working tree + .\release.ps1 + + # Auto commit tracked/untracked changes then release + .\release.ps1 -AutoCommit -CommitMessage "chore: update skill config" + + # Dry run (show what would happen) + .\release.ps1 -DryRun + + # Custom tag prefix + .\release.ps1 -Prefix "v" -Message "正式发布" + +.NOTES + Requires: git, PowerShell 5+ +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) +if (Test-Path $sharedScript) { + & $sharedScript @PSBoundParameters + exit $LASTEXITCODE +} + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string]$Args) + Write-Host ">> git $Args" -ForegroundColor DarkGray + & cmd /c "git $Args" + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } +} + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string]$Args) + $output = & cmd /c "git $Args" 2>$null + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } + return @($output) +} + +function Test-Repo { + & git rev-parse --is-inside-work-tree *> $null + return ($LASTEXITCODE -eq 0) +} + +function Get-CurrentBranch { + $b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim() + return $b +} + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput "status --porcelain") + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput "tag --list" + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + & git status --short + throw "Abort: dirty working tree." + } + + # 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启 + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git "add -A" + Invoke-Git "commit -m `"$commitMsg`"" +} + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + Invoke-Git "fetch --tags --prune origin" + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null) + $hasUpstream = ($LASTEXITCODE -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git "push" + } else { + Invoke-Git "push -u origin $branch" + } + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + + $existing = @(Get-GitOutput "tag --list `"$nextTag`"") + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-Git "tag -a $nextTag -m `"$Message`"" + Invoke-Git "push origin $nextTag" + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/api-key-vault/scripts/vault.py b/api-key-vault/scripts/vault.py new file mode 100644 index 0000000..e47149a --- /dev/null +++ b/api-key-vault/scripts/vault.py @@ -0,0 +1,105 @@ +import sys +import os + +# .env文件路径:固定在api-key-vault根目录 +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ENV_FILE = os.path.join(BASE_DIR, ".env") + + +def load_keys(): + """读取.env文件,返回dict""" + keys = {} + if not os.path.exists(ENV_FILE): + return keys + with open(ENV_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + name, _, value = line.partition("=") + keys[name.strip()] = value.strip() + return keys + + +def save_keys(keys): + """把dict写回.env文件""" + with open(ENV_FILE, "w", encoding="utf-8") as f: + f.write("# API Key Vault - 自动生成,请勿手动乱改\n") + for name, value in keys.items(): + f.write(f"{name}={value}\n") + + +def cmd_get(name): + keys = load_keys() + if name not in keys: + print("ERROR:KEY_NOT_FOUND") + else: + print(keys[name]) + + +def cmd_set(name, value): + keys = load_keys() + keys[name] = value + save_keys(keys) + print(f"✅ 已保存:{name}") + + +def cmd_list(): + keys = load_keys() + if not keys: + print("暂无已存储的Key") + return + print("已存储的Key列表:") + for name in keys: + value = keys[name] + # 只显示前4位和后4位,中间用*遮挡 + masked = value[:4] + "****" + value[-4:] if len(value) > 8 else "****" + print(f" · {name} = {masked}") + + +def cmd_delete(name): + keys = load_keys() + if name not in keys: + print(f"❌ 未找到:{name}") + else: + del keys[name] + save_keys(keys) + print(f"🗑 已删除:{name}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法:") + print(" python vault.py get ") + print(" python vault.py set ") + print(" python vault.py list") + print(" python vault.py delete ") + sys.exit(1) + + command = sys.argv[1].lower() + + if command == "get": + if len(sys.argv) < 3: + print("错误:get命令需要提供key名") + sys.exit(1) + cmd_get(sys.argv[2]) + + elif command == "set": + if len(sys.argv) < 4: + print("错误:set命令需要提供key名和key值") + sys.exit(1) + cmd_set(sys.argv[2], sys.argv[3]) + + elif command == "list": + cmd_list() + + elif command == "delete": + if len(sys.argv) < 3: + print("错误:delete命令需要提供key名") + sys.exit(1) + cmd_delete(sys.argv[2]) + + else: + print(f"错误:未知命令 {command}") + sys.exit(1) \ No newline at end of file diff --git a/content-manager/.github/workflows/release_skill.yaml b/content-manager/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..21617a6 --- /dev/null +++ b/content-manager/.github/workflows/release_skill.yaml @@ -0,0 +1,11 @@ +name: 技能自动化发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/content-manager/SKILL.md b/content-manager/SKILL.md new file mode 100644 index 0000000..27a918b --- /dev/null +++ b/content-manager/SKILL.md @@ -0,0 +1,82 @@ +--- +name: 内容管理 +description: 文章、图片、视频分表分目录管理;文章正文在库内,图/视频文件在数据目录、库内仅存相对路径。代码分层:db 仓储 / services 业务 / cli 入口。 +version: 2.0.0 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: content-manager + emoji: "📝" + category: "通用" + dependencies: + required: + - llm-manager + auto_install: false +allowed-tools: + - bash +--- + +# 内容管理器(文章 / 图片 / 视频) + +## 代码结构(MVC 分层) + +- `scripts/main.py`:仅入口与环境准备(`sys.path`、Windows UTF-8)。 +- `content_manager/cli/`:参数解析与分发(Controller)。 +- `content_manager/services/`:业务编排(调用仓储、llm-manager、文件复制)。 +- `content_manager/db/`:SQLite 连接、建表迁移、按表划分的 **repository**(只做 SQL,不含业务规则)。 +- `content_manager/config.py`:数据根路径、技能数据目录。 +- `content_manager/constants.py`:提示词种子、平台别名等常量。 + +## 数据与表结构 + +- 库路径:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/content-manager/content-manager.db` +- **文章** 表 `articles`:`title`、`body`、`content_html`、`status`、`source`、`llm_target`、`account_id`、`error_msg`、`extra_json`、时间戳等(正文在库内)。 +- **图片** 表 `images`:仅存 **`file_path`(相对技能数据目录)** 及 `title`、`status`、`source` 等元数据;二进制在 `{…}/content-manager/images//`。 +- **视频** 表 `videos`:同上,文件在 `{…}/content-manager/videos//`;可选 `duration_ms`。 +- 提示词相关表:`prompt_templates`、`prompt_template_usage`(供文章 `generate`)。 + +从旧版「TEXT 主键」`articles` 库启动时会自动迁移到新结构。 + +## 常用命令 + +将 `{baseDir}` 换为技能根目录。一级子命令为 **`article` / `image` / `video`**。 + +### 文章 + +```bash +python {baseDir}/scripts/main.py article list +python {baseDir}/scripts/main.py article get +python {baseDir}/scripts/main.py article add --title "标题" --body "正文" +python {baseDir}/scripts/main.py article add --title "标题" --body-file D:\path\article.md +python {baseDir}/scripts/main.py article import-json D:\path\articles.json +python {baseDir}/scripts/main.py article generate 豆包 搜狐号 "RPA降本增效" +python {baseDir}/scripts/main.py article prompt-list 搜狐号 --limit 20 +python {baseDir}/scripts/main.py article delete +python {baseDir}/scripts/main.py article feedback published +python {baseDir}/scripts/main.py article save <单行正文> +``` + +`article get` 输出 JSON:`id`、`title`、`content`、`content_html`、`status` 等。 + +### 图片 / 视频(库内只存路径) + +```bash +python {baseDir}/scripts/main.py image add --file D:\a.png [--title "说明"] +python {baseDir}/scripts/main.py image list +python {baseDir}/scripts/main.py image get <id> +python {baseDir}/scripts/main.py image delete <id> +python {baseDir}/scripts/main.py image feedback <id> published <account_id> + +python {baseDir}/scripts/main.py video add --file D:\a.mp4 [--title "说明"] [--duration-ms 120000] +python {baseDir}/scripts/main.py video list +python {baseDir}/scripts/main.py video get <id> +python {baseDir}/scripts/main.py video delete <id> +python {baseDir}/scripts/main.py video feedback <id> failed <account_id> "原因" +``` + +`image get` / `video get` 的 JSON 含 `file_path`(相对)、`absolute_path`(解析后绝对路径)。 + +## 环境变量 + +- `JIANGCHANG_DATA_ROOT`、`JIANGCHANG_USER_ID`:与 account-manager 一致。 +- `llm-manager` 依赖其自身环境与账号/API Key 配置。 diff --git a/content-manager/content_manager/__init__.py b/content-manager/content_manager/__init__.py new file mode 100644 index 0000000..e1f6a5e --- /dev/null +++ b/content-manager/content_manager/__init__.py @@ -0,0 +1 @@ +# content-manager 技能:文章 / 图片 / 视频 分层包 diff --git a/content-manager/content_manager/cli/__init__.py b/content-manager/content_manager/cli/__init__.py new file mode 100644 index 0000000..a850802 --- /dev/null +++ b/content-manager/content_manager/cli/__init__.py @@ -0,0 +1 @@ +# CLI:解析参数并调用 services diff --git a/content-manager/content_manager/cli/app.py b/content-manager/content_manager/cli/app.py new file mode 100644 index 0000000..9cead39 --- /dev/null +++ b/content-manager/content_manager/cli/app.py @@ -0,0 +1,261 @@ +"""CLI 入口:argparse 装配与分发(Controller)。""" + +from __future__ import annotations + +import argparse +import os +import sys +from typing import List, Optional + +from content_manager.services import article_service, image_service, video_service +from content_manager.services.article_service import resolve_publish_platform +from content_manager.util.argparse_zh import ZhArgumentParser + + +def _print_root_usage_zh() -> None: + print( + """内容管理:请指定资源类型子命令。 + + python main.py article list + python main.py article get 1 + python main.py article add --title "标题" --body-file 文章.md + python main.py article generate 豆包 搜狐号 RPA降本增效 + python main.py image add --file D:\\\\a.png [--title "说明"] + python main.py video add --file D:\\\\a.mp4 [--title "说明"] [--duration-ms 120000] + +查看完整说明:python main.py -h""" + ) + + +def _handle_article_add(args: argparse.Namespace) -> None: + if args.body_file: + fp = os.path.abspath(args.body_file) + try: + with open(fp, encoding="utf-8") as f: + body = f.read() + except OSError as e: + print(f"❌ 无法读取正文文件:{fp}\n原因:{e}") + sys.exit(1) + else: + body = args.body or "" + article_service.cmd_add(args.title, body, source="manual") + + +def _handle_article_import(args: argparse.Namespace) -> None: + article_service.cmd_import_json(args.path) + + +def _handle_article_generate(args: argparse.Namespace) -> None: + raw_parts = [str(x).strip() for x in (args.generate_args or []) if str(x).strip()] + if not raw_parts: + print("❌ 缺少主题或关键词。") + print("示例:python main.py article generate 豆包 搜狐号 RPA降本增效") + sys.exit(1) + platform_guess = resolve_publish_platform(raw_parts[0]) + if platform_guess and len(raw_parts) == 1: + print("❌ 缺少主题或关键词。") + sys.exit(1) + if platform_guess and len(raw_parts) >= 2: + publish_platform = platform_guess + topic = " ".join(raw_parts[1:]).strip() + else: + publish_platform = "common" + topic = " ".join(raw_parts).strip() + if not topic: + print("❌ 主题或关键词不能为空。") + sys.exit(1) + article_service.cmd_generate( + args.llm_target, + topic, + publish_platform=publish_platform, + title=getattr(args, "title", None), + ) + + +def _handle_article_feedback(args: argparse.Namespace) -> None: + article_service.cmd_feedback(args.article_id, args.status, args.account_id, args.error_msg) + + +def _handle_article_save_legacy(args: argparse.Namespace) -> None: + article_service.cmd_save(args.legacy_id, args.legacy_title, args.legacy_content) + + +def _handle_image_add(args: argparse.Namespace) -> None: + image_service.cmd_add(args.file, title=getattr(args, "title", None)) + + +def _handle_image_feedback(args: argparse.Namespace) -> None: + image_service.cmd_feedback(args.image_id, args.status, args.account_id, args.error_msg) + + +def _handle_video_add(args: argparse.Namespace) -> None: + video_service.cmd_add( + args.file, + title=getattr(args, "title", None), + duration_ms=getattr(args, "duration_ms", None), + ) + + +def _handle_video_feedback(args: argparse.Namespace) -> None: + video_service.cmd_feedback(args.video_id, args.status, args.account_id, args.error_msg) + + +def build_parser() -> ZhArgumentParser: + fmt = argparse.RawDescriptionHelpFormatter + p = ZhArgumentParser( + prog="main.py", + description="内容管理:文章(正文在库内)与图片/视频(文件在数据目录,库内仅存路径)。", + epilog="示例见各子命令 -h;一级分组:article / image / video", + formatter_class=fmt, + ) + sub = p.add_subparsers( + dest="resource", + required=True, + metavar="资源类型", + help="article 文章 | image 图片 | video 视频", + parser_class=ZhArgumentParser, + ) + + # ----- article ----- + art = sub.add_parser("article", help="文章:正文与元数据在 SQLite", formatter_class=fmt) + art_sub = art.add_subparsers( + dest="article_cmd", + required=True, + metavar="子命令", + parser_class=ZhArgumentParser, + ) + + sp = art_sub.add_parser("list", help="列出文章") + sp.add_argument("--limit", type=int, default=10) + sp.add_argument("--max-chars", type=int, default=50) + sp.set_defaults(handler=lambda a: article_service.cmd_list(limit=a.limit, max_chars=a.max_chars)) + + sp = art_sub.add_parser("get", help="按 id 输出 JSON") + sp.add_argument("article_id", metavar="文章id") + sp.set_defaults(handler=lambda a: article_service.cmd_get(a.article_id)) + + sp = art_sub.add_parser( + "add", + help="新增文章", + formatter_class=fmt, + epilog="示例:python main.py article add --title \"标题\" --body \"正文\"", + ) + sp.add_argument("--title", required=True) + g = sp.add_mutually_exclusive_group(required=True) + g.add_argument("--body-file", metavar="路径") + g.add_argument("--body", metavar="正文") + sp.set_defaults(handler=_handle_article_add) + + sp = art_sub.add_parser("import-json", help="从 JSON 批量导入") + sp.add_argument("path", metavar="JSON路径") + sp.set_defaults(handler=_handle_article_import) + + sp = art_sub.add_parser("generate", help="调用 llm-manager 生成并入库", formatter_class=fmt) + sp.add_argument("llm_target", metavar="大模型目标") + sp.add_argument("generate_args", nargs="+", metavar="生成参数") + sp.add_argument("--title", metavar="标题", default=None) + sp.set_defaults(handler=_handle_article_generate) + + sp = art_sub.add_parser("prompt-list", help="查看提示词模板") + sp.add_argument("platform", nargs="?", default=None, metavar="发布平台") + sp.add_argument("--limit", type=int, default=30) + sp.set_defaults(handler=lambda a: article_service.cmd_prompt_list(a.platform, a.limit)) + + sp = art_sub.add_parser("delete", help="删除文章") + sp.add_argument("article_id", metavar="文章id") + sp.set_defaults(handler=lambda a: article_service.cmd_delete(a.article_id)) + + sp = art_sub.add_parser("feedback", help="回写发布状态", formatter_class=fmt) + sp.add_argument("article_id", metavar="文章id") + sp.add_argument("status", metavar="状态") + sp.add_argument("account_id", nargs="?", default=None, metavar="账号") + sp.add_argument("error_msg", nargs="?", default=None, metavar="错误说明") + sp.set_defaults(handler=_handle_article_feedback) + + sp = art_sub.add_parser("save", help="旧版单行正文保存", formatter_class=fmt) + sp.add_argument("legacy_id", metavar="id") + sp.add_argument("legacy_title", metavar="标题") + sp.add_argument("legacy_content", metavar="正文一行") + sp.set_defaults(handler=_handle_article_save_legacy) + + # ----- image ----- + img = sub.add_parser("image", help="图片:文件在数据目录,images 表存相对路径", formatter_class=fmt) + img_sub = img.add_subparsers( + dest="image_cmd", + required=True, + metavar="子命令", + parser_class=ZhArgumentParser, + ) + + sp = img_sub.add_parser("list", help="列出图片") + sp.add_argument("--limit", type=int, default=20) + sp.add_argument("--max-chars", type=int, default=80) + sp.set_defaults(handler=lambda a: image_service.cmd_list(limit=a.limit, max_chars=a.max_chars)) + + sp = img_sub.add_parser("get", help="按 id 输出 JSON(含 absolute_path)") + sp.add_argument("image_id", metavar="图片id") + sp.set_defaults(handler=lambda a: image_service.cmd_get(a.image_id)) + + sp = img_sub.add_parser("add", help="从本地文件复制入库", formatter_class=fmt) + sp.add_argument("--file", required=True, metavar="文件", help="源图片路径") + sp.add_argument("--title", default=None, metavar="标题", help="可选说明") + sp.set_defaults(handler=_handle_image_add) + + sp = img_sub.add_parser("delete", help="删除记录与磁盘目录") + sp.add_argument("image_id", metavar="图片id") + sp.set_defaults(handler=lambda a: image_service.cmd_delete(a.image_id)) + + sp = img_sub.add_parser("feedback", help="回写状态", formatter_class=fmt) + sp.add_argument("image_id", metavar="图片id") + sp.add_argument("status", metavar="状态") + sp.add_argument("account_id", nargs="?", default=None, metavar="账号") + sp.add_argument("error_msg", nargs="?", default=None, metavar="错误说明") + sp.set_defaults(handler=_handle_image_feedback) + + # ----- video ----- + vid = sub.add_parser("video", help="视频:文件在数据目录,videos 表存相对路径", formatter_class=fmt) + vid_sub = vid.add_subparsers( + dest="video_cmd", + required=True, + metavar="子命令", + parser_class=ZhArgumentParser, + ) + + sp = vid_sub.add_parser("list", help="列出视频") + sp.add_argument("--limit", type=int, default=20) + sp.add_argument("--max-chars", type=int, default=80) + sp.set_defaults(handler=lambda a: video_service.cmd_list(limit=a.limit, max_chars=a.max_chars)) + + sp = vid_sub.add_parser("get", help="按 id 输出 JSON") + sp.add_argument("video_id", metavar="视频id") + sp.set_defaults(handler=lambda a: video_service.cmd_get(a.video_id)) + + sp = vid_sub.add_parser("add", help="从本地文件复制入库", formatter_class=fmt) + sp.add_argument("--file", required=True, metavar="文件") + sp.add_argument("--title", default=None, metavar="标题") + sp.add_argument("--duration-ms", type=int, default=None, metavar="毫秒", help="可选时长") + sp.set_defaults(handler=_handle_video_add) + + sp = vid_sub.add_parser("delete", help="删除记录与磁盘目录") + sp.add_argument("video_id", metavar="视频id") + sp.set_defaults(handler=lambda a: video_service.cmd_delete(a.video_id)) + + sp = vid_sub.add_parser("feedback", help="回写状态", formatter_class=fmt) + sp.add_argument("video_id", metavar="视频id") + sp.add_argument("status", metavar="状态") + sp.add_argument("account_id", nargs="?", default=None, metavar="账号") + sp.add_argument("error_msg", nargs="?", default=None, metavar="错误说明") + sp.set_defaults(handler=_handle_video_feedback) + + return p + + +def main(argv: Optional[List[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + if not argv: + _print_root_usage_zh() + return 1 + parser = build_parser() + args = parser.parse_args(argv) + args.handler(args) + return 0 diff --git a/content-manager/content_manager/config.py b/content-manager/content_manager/config.py new file mode 100644 index 0000000..7efa2e2 --- /dev/null +++ b/content-manager/content_manager/config.py @@ -0,0 +1,47 @@ +"""路径与环境:与 account-manager 一致的数据根目录。""" + +from __future__ import annotations + +import os +import sys +from typing import Optional + +SKILL_SLUG = "content-manager" + + +def get_skill_root() -> str: + # content_manager/config.py -> 技能根 content-manager/ + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_openclaw_root() -> str: + return os.path.dirname(get_skill_root()) + + +def get_data_root() -> str: + env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() + if env: + return env + if sys.platform == "win32": + return r"D:\jiangchang-data" + return os.path.join(os.path.expanduser("~"), ".jiangchang-data") + + +def get_user_id() -> str: + return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon" + + +def get_skill_data_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) + os.makedirs(path, exist_ok=True) + return path + + +def get_db_path() -> str: + return os.path.join(get_skill_data_dir(), "content-manager.db") + + +def resolve_stored_path(relative_file_path: str) -> str: + """库内相对路径 -> 绝对路径。""" + rel = (relative_file_path or "").strip().replace("\\", "/").lstrip("/") + return os.path.normpath(os.path.join(get_skill_data_dir(), rel)) diff --git a/content-manager/content_manager/constants.py b/content-manager/content_manager/constants.py new file mode 100644 index 0000000..44990c0 --- /dev/null +++ b/content-manager/content_manager/constants.py @@ -0,0 +1,103 @@ +"""与业务相关的常量(提示词种子、平台别名、CLI 提示文案)。""" + +from __future__ import annotations + +from typing import Dict, List, Set + +CLI_REQUIRED_ZH = { + "cmd": "一级子命令:article 文章 | image 图片 | video 视频", + "子命令": "二级子命令,用 -h 查看该分组下的命令", + "llm_target": "大模型目标:写平台名(如 豆包、DeepSeek、Kimi)或 account-manager 里已登录账号的纯数字 id", + "大模型目标": "大模型目标:写平台名(如 豆包、DeepSeek、Kimi)或 account-manager 里已登录账号的纯数字 id", + "生成参数": "生成参数:格式是「模型 [发布平台] 主题/关键词」,例如:python main.py article generate 豆包 搜狐号 RPA降本增效", + "主题": "主题或关键词:至少填写一项,用于自动套用提示词模板", + "--title": "标题:写 --title \"文章标题\";add / generate / image add / video add 会用到", + "标题": "标题:写 --title \"文章标题\"", + "--body": "正文:写 --body \"短文\";与 --body-file 二选一", + "正文": "正文:写 --body \"短文\";与 --body-file 二选一", + "--body-file": "正文文件:写 --body-file 后再写 UTF-8 文件路径;与 --body 二选一", + "路径": "正文文件:写 --body-file 后再写 UTF-8 文件路径;与 --body 二选一", + "--file": "本地文件路径:图片或视频源文件", + "文件": "本地文件路径:图片或视频源文件", + "path": "JSON 文件路径:写在 import-json 后面,例如 D:\\\\data\\\\articles.json", + "JSON路径": "JSON 文件路径:写在 import-json 后面,例如 D:\\\\data\\\\articles.json", + "article_id": "文章编号:整数 id,可先执行 article list 看最左一列", + "文章id": "文章编号:整数 id,可先执行 article list 看最左一列", + "image_id": "图片编号:整数 id,可先执行 image list", + "图片id": "图片编号:整数 id,可先执行 image list", + "video_id": "视频编号:整数 id,可先执行 video list", + "视频id": "视频编号:整数 id,可先执行 video list", + "legacy_id": "id:若是已有文章的数字 id 则更新;否则新建一篇", + "id": "id:若是已有文章的数字 id 则更新;否则新建一篇", + "legacy_title": "标题", + "legacy_content": "正文(整段须在一行内,不要换行)", + "正文一行": "正文(整段须在一行内,不要换行)", + "状态": "状态:例如 published(已发布)或 failed(失败)", + "账号": "账号标识:可选,给发布记录用", + "错误说明": "错误说明:可选,发布失败时写上原因", +} + +PUBLISH_PLATFORM_ALIASES: Dict[str, Set[str]] = { + "common": {"common", "通用", "默认", "general", "all"}, + "sohu": {"sohu", "搜狐", "搜狐号"}, + "toutiao": {"toutiao", "头条", "头条号", "今日头条"}, + "wechat": {"wechat", "weixin", "wx", "公众号", "微信公众号", "微信"}, +} + +PUBLISH_PLATFORM_CN = { + "common": "通用", + "sohu": "搜狐号", + "toutiao": "头条号", + "wechat": "微信公众号", +} + +PROMPT_TEMPLATE_SEEDS: Dict[str, List[str]] = { + "common": [ + "请围绕主题“{topic}”写一篇结构完整、可直接发布的新媒体文章,输出纯正文,不要解释。", + "请以“{topic}”为核心,写一篇适合中文互联网平台发布的文章,语言自然、观点清晰、可读性强。", + "围绕“{topic}”写一篇实用向内容,要求有标题、导语、分点展开和结语,整体逻辑清楚。", + "请写一篇关于“{topic}”的科普文章,面向普通读者,避免术语堆砌,语气专业但易懂。", + "请从痛点、原因、方法、案例四个部分展开,写一篇主题为“{topic}”的原创内容。", + "围绕“{topic}”写一篇信息密度高但不枯燥的文章,要求段落清晰、句子简洁。", + "请就“{topic}”写一篇观点型文章,先给结论,再给依据和建议,最后总结。", + "请生成一篇主题为“{topic}”的内容,适合移动端阅读,段落不宜过长,便于快速浏览。", + "围绕“{topic}”撰写一篇可发布文章,避免空话套话,优先给出可执行建议。", + "请以“{topic}”为题写文,要求开头抓人、中段有干货、结尾有行动建议。", + ], + "sohu": [ + "你在为搜狐号写稿。请围绕“{topic}”写一篇原创文章,风格稳重、信息扎实,适合搜狐号读者阅读。", + "请按搜狐号内容风格,围绕“{topic}”写一篇逻辑清晰、观点明确的文章,输出纯正文。", + "面向搜狐号发布场景,生成主题“{topic}”文章,要求有吸引力标题和清晰分段。", + "请写一篇搜狐号可发布稿件,主题“{topic}”,强调实用价值与可读性。", + "围绕“{topic}”写搜狐号文章:先引出问题,再给分析,最后给建议。", + "请生成一篇适配搜狐号用户阅读习惯的文章,主题是“{topic}”,语言自然且有深度。", + "请写一篇“{topic}”主题稿,适合搜狐号发布,避免口水话,突出真实信息和案例。", + "为搜狐号生成“{topic}”文章,结构为:导语-正文三段-总结,输出可直接发布内容。", + "请围绕“{topic}”写一篇搜狐号文章,强调观点清晰、段落层次分明、结尾有启发。", + "围绕“{topic}”产出搜狐号稿件,内容原创、连贯、可读,避免模板化表达。", + ], + "toutiao": [ + "请按头条号风格围绕“{topic}”写文章,开头3句要抓人,正文信息密度高。", + "请写一篇头条号可发布内容,主题“{topic}”,要求标题感强、节奏快、观点明确。", + "围绕“{topic}”写头条稿件,语言更口语化、易传播,适当加入场景化表达。", + "请生成“{topic}”头条文章:开头抛问题,中段拆解,结尾给结论。", + "为头条号创作“{topic}”文章,注重读者停留与完读,段落短、信息集中。", + "请写一篇“{topic}”主题头条文,强调实用技巧和可执行方法。", + "围绕“{topic}”生成头条风格内容,避免空泛,突出细节与案例。", + "请按头条读者偏好写“{topic}”文章,语气直接,观点鲜明,结尾有行动建议。", + "请围绕“{topic}”写头条号稿件,确保逻辑清楚、表达简洁、节奏紧凑。", + "写一篇适合头条号发布的“{topic}”文章,要求易懂、好读、可传播。", + ], + "wechat": [ + "请按公众号长文风格围绕“{topic}”写稿,语气克制、叙述完整、可深度阅读。", + "请写一篇适合公众号发布的“{topic}”文章,包含引言、分节标题和总结。", + "围绕“{topic}”写公众号文章,强调逻辑深度与观点完整性,输出纯正文。", + "请创作“{topic}”公众号稿件,要求有故事化开头、干货正文、结尾金句。", + "请按公众号读者习惯,写一篇主题“{topic}”的内容,表达自然、层次清晰。", + "生成一篇“{topic}”微信公众号文章,强调洞察与方法论,避免碎片化表达。", + "请围绕“{topic}”写公众号稿,风格专业可信,段落清楚且有小标题。", + "请写一篇“{topic}”公众号内容,结构为:问题提出-原因分析-解决建议-结语。", + "请生成“{topic}”公众号文章,注重阅读体验,段落与节奏适合移动端。", + "围绕“{topic}”撰写可直接发公众号的文章,要求原创、完整、可读。", + ], +} diff --git a/content-manager/content_manager/db/__init__.py b/content-manager/content_manager/db/__init__.py new file mode 100644 index 0000000..a3e11ee --- /dev/null +++ b/content-manager/content_manager/db/__init__.py @@ -0,0 +1,3 @@ +from content_manager.db.connection import get_conn, init_db + +__all__ = ["get_conn", "init_db"] diff --git a/content-manager/content_manager/db/articles_repository.py b/content-manager/content_manager/db/articles_repository.py new file mode 100644 index 0000000..0dc791f --- /dev/null +++ b/content-manager/content_manager/db/articles_repository.py @@ -0,0 +1,122 @@ +"""articles 表:仅负责 SQL 读写,不含业务规则。""" + +from __future__ import annotations + +import sqlite3 +from typing import Any, List, Optional, Tuple + + +def insert_article( + conn: sqlite3.Connection, + title: str, + body: str, + content_html: Optional[str], + status: str, + source: str, + account_id: Optional[str], + error_msg: Optional[str], + llm_target: Optional[str], + extra_json: Optional[str], + created_at: int, + updated_at: int, +) -> int: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO articles ( + title, body, content_html, status, source, account_id, error_msg, llm_target, extra_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + title, + body, + content_html, + status, + source, + account_id, + error_msg, + llm_target, + extra_json, + created_at, + updated_at, + ), + ) + return int(cur.lastrowid) + + +def update_article_body( + conn: sqlite3.Connection, + article_id: int, + title: str, + body: str, + updated_at: int, +) -> None: + cur = conn.cursor() + cur.execute( + """ + UPDATE articles SET title = ?, body = ?, updated_at = ? + WHERE id = ? + """, + (title, body, updated_at, article_id), + ) + + +def fetch_by_id(conn: sqlite3.Connection, article_id: int) -> Optional[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT id, title, body, content_html, status, source, account_id, error_msg, + llm_target, extra_json, created_at, updated_at + FROM articles WHERE id = ? + """, + (article_id,), + ) + return cur.fetchone() + + +def exists_id(conn: sqlite3.Connection, article_id: int) -> bool: + cur = conn.cursor() + cur.execute("SELECT id FROM articles WHERE id = ?", (article_id,)) + return cur.fetchone() is not None + + +def list_recent(conn: sqlite3.Connection, limit: int) -> List[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT + id, title, body, content_html, + status, source, account_id, error_msg, llm_target, extra_json, + created_at, updated_at + FROM articles ORDER BY updated_at DESC, id DESC + LIMIT ? + """, + (int(limit),), + ) + return list(cur.fetchall()) + + +def delete_by_id(conn: sqlite3.Connection, article_id: int) -> int: + cur = conn.cursor() + cur.execute("DELETE FROM articles WHERE id = ?", (article_id,)) + return int(cur.rowcount) + + +def update_feedback( + conn: sqlite3.Connection, + article_id: int, + status: str, + account_id: Optional[str], + error_msg: Optional[str], + updated_at: int, +) -> None: + cur = conn.cursor() + cur.execute( + """ + UPDATE articles + SET status = ?, account_id = ?, error_msg = ?, updated_at = ? + WHERE id = ? + """, + (status, account_id, error_msg, updated_at, article_id), + ) diff --git a/content-manager/content_manager/db/connection.py b/content-manager/content_manager/db/connection.py new file mode 100644 index 0000000..b1ee994 --- /dev/null +++ b/content-manager/content_manager/db/connection.py @@ -0,0 +1,113 @@ +"""数据库连接与初始化(建表、文章旧库迁移、提示词种子)。""" + +from __future__ import annotations + +import sqlite3 +from typing import TYPE_CHECKING + +from content_manager.config import get_db_path +from content_manager.db.schema import ( + ARTICLES_TABLE_SQL, + IMAGES_TABLE_SQL, + PROMPT_TEMPLATE_USAGE_TABLE_SQL, + PROMPT_TEMPLATES_TABLE_SQL, + VIDEOS_TABLE_SQL, +) +from content_manager.util.timeutil import now_unix, parse_ts_to_unix + +if TYPE_CHECKING: + pass + + +def get_conn() -> sqlite3.Connection: + return sqlite3.connect(get_db_path()) + + +def _is_legacy_articles_table(cur: sqlite3.Cursor) -> bool: + cur.execute("PRAGMA table_info(articles)") + rows = cur.fetchall() + if not rows: + return False + for _cid, name, ctype, _nn, _d, _pk in rows: + if name == "id" and ctype and ctype.upper() == "INTEGER": + return False + cur.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='articles'") + row = cur.fetchone() + if row and row[0] and "TEXT" in row[0] and "id" in row[0]: + return True + return False + + +def _migrate_legacy_articles(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + cur.executescript( + """ + CREATE TABLE _articles_migrated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL, + content_html TEXT, + status TEXT NOT NULL DEFAULT 'draft', + source TEXT NOT NULL DEFAULT 'manual', + account_id TEXT, + error_msg TEXT, + llm_target TEXT, + extra_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + """ + ) + cur.execute( + "SELECT id, title, content, content_html, status, account_id, error_msg, created_at, updated_at FROM articles" + ) + ts = now_unix() + for row in cur.fetchall(): + _oid, title, content, content_html, status, account_id, error_msg, cat, uat = row + body = content or "" + ch = content_html if content_html else None + cts = parse_ts_to_unix(cat) or ts + uts = parse_ts_to_unix(uat) or ts + cur.execute( + """ + INSERT INTO _articles_migrated ( + title, body, content_html, status, source, account_id, error_msg, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'import', ?, ?, ?, ?) + """, + ( + title or "", + body, + ch, + (status or "draft"), + account_id, + error_msg, + cts, + uts, + ), + ) + cur.execute("DROP TABLE articles") + cur.execute("ALTER TABLE _articles_migrated RENAME TO articles") + conn.commit() + + +def init_db() -> None: + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='articles'") + if cur.fetchone(): + if _is_legacy_articles_table(cur): + _migrate_legacy_articles(conn) + else: + cur.executescript(ARTICLES_TABLE_SQL) + cur.executescript(IMAGES_TABLE_SQL) + cur.executescript(VIDEOS_TABLE_SQL) + cur.executescript(PROMPT_TEMPLATES_TABLE_SQL) + cur.executescript(PROMPT_TEMPLATE_USAGE_TABLE_SQL) + from content_manager.db.prompts_repository import seed_prompt_templates_if_empty + + seed_prompt_templates_if_empty(conn.cursor()) + conn.commit() + finally: + conn.close() diff --git a/content-manager/content_manager/db/images_repository.py b/content-manager/content_manager/db/images_repository.py new file mode 100644 index 0000000..932cce5 --- /dev/null +++ b/content-manager/content_manager/db/images_repository.py @@ -0,0 +1,88 @@ +"""images 表:仅保存文件相对路径等元数据。""" + +from __future__ import annotations + +import sqlite3 +from typing import Any, List, Optional, Tuple + + +def insert_row( + conn: sqlite3.Connection, + file_path: str, + title: Optional[str], + status: str, + source: str, + account_id: Optional[str], + error_msg: Optional[str], + extra_json: Optional[str], + created_at: int, + updated_at: int, +) -> int: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO images ( + file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at), + ) + return int(cur.lastrowid) + + +def update_file_path(conn: sqlite3.Connection, image_id: int, file_path: str, updated_at: int) -> None: + cur = conn.cursor() + cur.execute( + "UPDATE images SET file_path = ?, updated_at = ? WHERE id = ?", + (file_path, updated_at, image_id), + ) + + +def fetch_by_id(conn: sqlite3.Connection, image_id: int) -> Optional[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT id, file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at + FROM images WHERE id = ? + """, + (image_id,), + ) + return cur.fetchone() + + +def list_recent(conn: sqlite3.Connection, limit: int) -> List[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT id, file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at + FROM images ORDER BY updated_at DESC, id DESC + LIMIT ? + """, + (int(limit),), + ) + return list(cur.fetchall()) + + +def delete_by_id(conn: sqlite3.Connection, image_id: int) -> int: + cur = conn.cursor() + cur.execute("DELETE FROM images WHERE id = ?", (image_id,)) + return int(cur.rowcount) + + +def update_feedback( + conn: sqlite3.Connection, + image_id: int, + status: str, + account_id: Optional[str], + error_msg: Optional[str], + updated_at: int, +) -> None: + cur = conn.cursor() + cur.execute( + """ + UPDATE images + SET status = ?, account_id = ?, error_msg = ?, updated_at = ? + WHERE id = ? + """, + (status, account_id, error_msg, updated_at, image_id), + ) diff --git a/content-manager/content_manager/db/prompts_repository.py b/content-manager/content_manager/db/prompts_repository.py new file mode 100644 index 0000000..dfb47ef --- /dev/null +++ b/content-manager/content_manager/db/prompts_repository.py @@ -0,0 +1,114 @@ +"""提示词模板:表内数据访问与种子。""" + +from __future__ import annotations + +import random +import sqlite3 +from typing import Any, Dict, List, Optional, Tuple + +from content_manager.constants import PROMPT_TEMPLATE_SEEDS, PUBLISH_PLATFORM_CN +from content_manager.util.timeutil import now_unix + + +def seed_prompt_templates_if_empty(cur: sqlite3.Cursor) -> None: + ts = now_unix() + for platform, templates in PROMPT_TEMPLATE_SEEDS.items(): + cur.execute("SELECT COUNT(*) FROM prompt_templates WHERE platform = ?", (platform,)) + count = int(cur.fetchone()[0] or 0) + if count > 0: + continue + for idx, tpl in enumerate(templates, start=1): + cur.execute( + """ + INSERT INTO prompt_templates (platform, name, template_text, is_active, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?) + """, + (platform, f"{PUBLISH_PLATFORM_CN.get(platform, platform)}模板{idx}", tpl, ts, ts), + ) + + +def count_by_platform(cur: sqlite3.Cursor, platform: str) -> int: + cur.execute("SELECT COUNT(*) FROM prompt_templates WHERE platform = ?", (platform,)) + return int(cur.fetchone()[0] or 0) + + +def fetch_active_templates(conn: sqlite3.Connection, platform: str) -> List[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT id, platform, name, template_text + FROM prompt_templates + WHERE platform = ? AND is_active = 1 + ORDER BY id ASC + """, + (platform,), + ) + return list(cur.fetchall()) + + +def fetch_common_fallback(conn: sqlite3.Connection) -> List[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT id, platform, name, template_text + FROM prompt_templates + WHERE platform = 'common' AND is_active = 1 + ORDER BY id ASC + """ + ) + return list(cur.fetchall()) + + +def pick_random_template(rows: List[Tuple[Any, ...]]) -> Optional[Dict[str, Any]]: + if not rows: + return None + rid, p, name, text = random.choice(rows) + return {"id": int(rid), "platform": p, "name": name, "template_text": text} + + +def insert_usage( + conn: sqlite3.Connection, + template_id: int, + llm_target: str, + platform: str, + topic: str, + article_id: Optional[int], +) -> None: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO prompt_template_usage (template_id, llm_target, platform, topic, article_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (template_id, llm_target, platform, topic, article_id, now_unix()), + ) + + +def list_templates( + conn: sqlite3.Connection, + platform: Optional[str], + limit: int, +) -> List[Tuple[Any, ...]]: + cur = conn.cursor() + if platform: + cur.execute( + """ + SELECT id, platform, name, is_active, updated_at + FROM prompt_templates + WHERE platform = ? + ORDER BY id DESC + LIMIT ? + """, + (platform, int(limit)), + ) + else: + cur.execute( + """ + SELECT id, platform, name, is_active, updated_at + FROM prompt_templates + ORDER BY id DESC + LIMIT ? + """, + (int(limit),), + ) + return list(cur.fetchall()) diff --git a/content-manager/content_manager/db/schema.py b/content-manager/content_manager/db/schema.py new file mode 100644 index 0000000..a20c78a --- /dev/null +++ b/content-manager/content_manager/db/schema.py @@ -0,0 +1,73 @@ +"""建表 SQL(不含迁移逻辑)。""" + +ARTICLES_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL, + content_html TEXT, + status TEXT NOT NULL DEFAULT 'draft', + source TEXT NOT NULL DEFAULT 'manual', + account_id TEXT, + error_msg TEXT, + llm_target TEXT, + extra_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +""" + +IMAGES_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + title TEXT, + status TEXT NOT NULL DEFAULT 'draft', + source TEXT NOT NULL DEFAULT 'manual', + account_id TEXT, + error_msg TEXT, + extra_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +""" + +VIDEOS_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS videos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + title TEXT, + duration_ms INTEGER, + status TEXT NOT NULL DEFAULT 'draft', + source TEXT NOT NULL DEFAULT 'manual', + account_id TEXT, + error_msg TEXT, + extra_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +""" + +PROMPT_TEMPLATES_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS prompt_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + name TEXT NOT NULL, + template_text TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +""" + +PROMPT_TEMPLATE_USAGE_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS prompt_template_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + llm_target TEXT NOT NULL, + platform TEXT NOT NULL, + topic TEXT NOT NULL, + article_id INTEGER, + created_at INTEGER NOT NULL +); +""" diff --git a/content-manager/content_manager/db/videos_repository.py b/content-manager/content_manager/db/videos_repository.py new file mode 100644 index 0000000..3fcb5e0 --- /dev/null +++ b/content-manager/content_manager/db/videos_repository.py @@ -0,0 +1,100 @@ +"""videos 表:仅保存文件相对路径等元数据。""" + +from __future__ import annotations + +import sqlite3 +from typing import Any, List, Optional, Tuple + + +def insert_row( + conn: sqlite3.Connection, + file_path: str, + title: Optional[str], + duration_ms: Optional[int], + status: str, + source: str, + account_id: Optional[str], + error_msg: Optional[str], + extra_json: Optional[str], + created_at: int, + updated_at: int, +) -> int: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO videos ( + file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + file_path, + title, + duration_ms, + status, + source, + account_id, + error_msg, + extra_json, + created_at, + updated_at, + ), + ) + return int(cur.lastrowid) + + +def update_file_path(conn: sqlite3.Connection, video_id: int, file_path: str, updated_at: int) -> None: + cur = conn.cursor() + cur.execute( + "UPDATE videos SET file_path = ?, updated_at = ? WHERE id = ?", + (file_path, updated_at, video_id), + ) + + +def fetch_by_id(conn: sqlite3.Connection, video_id: int) -> Optional[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT id, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, created_at, updated_at + FROM videos WHERE id = ? + """, + (video_id,), + ) + return cur.fetchone() + + +def list_recent(conn: sqlite3.Connection, limit: int) -> List[Tuple[Any, ...]]: + cur = conn.cursor() + cur.execute( + """ + SELECT id, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, created_at, updated_at + FROM videos ORDER BY updated_at DESC, id DESC + LIMIT ? + """, + (int(limit),), + ) + return list(cur.fetchall()) + + +def delete_by_id(conn: sqlite3.Connection, video_id: int) -> int: + cur = conn.cursor() + cur.execute("DELETE FROM videos WHERE id = ?", (video_id,)) + return int(cur.rowcount) + + +def update_feedback( + conn: sqlite3.Connection, + video_id: int, + status: str, + account_id: Optional[str], + error_msg: Optional[str], + updated_at: int, +) -> None: + cur = conn.cursor() + cur.execute( + """ + UPDATE videos + SET status = ?, account_id = ?, error_msg = ?, updated_at = ? + WHERE id = ? + """, + (status, account_id, error_msg, updated_at, video_id), + ) diff --git a/content-manager/content_manager/services/__init__.py b/content-manager/content_manager/services/__init__.py new file mode 100644 index 0000000..8e9862c --- /dev/null +++ b/content-manager/content_manager/services/__init__.py @@ -0,0 +1 @@ +# 业务逻辑层(调用 db 仓储,不含 argparse) diff --git a/content-manager/content_manager/services/article_service.py b/content-manager/content_manager/services/article_service.py new file mode 100644 index 0000000..5d2b23e --- /dev/null +++ b/content-manager/content_manager/services/article_service.py @@ -0,0 +1,431 @@ +"""文章:业务规则与编排(调用仓储 + llm-manager)。""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from typing import Any, Dict, Optional + +from content_manager.config import get_openclaw_root +from content_manager.constants import PUBLISH_PLATFORM_CN, PUBLISH_PLATFORM_ALIASES +from content_manager.db import articles_repository as ar +from content_manager.db import prompts_repository as pr +from content_manager.db.connection import get_conn, init_db +from content_manager.util.timeutil import now_unix, unix_to_iso + + +def _row_to_public_dict(row: tuple) -> Dict[str, Any]: + rid, title, body, content_html, status, source, account_id, error_msg, llm_target, extra_json, cat, uat = row + d: Dict[str, Any] = { + "id": int(rid), + "title": title, + "content": body, + "content_html": content_html if content_html else body, + "status": status or "draft", + "source": source or "manual", + "account_id": account_id, + "error_msg": error_msg, + "llm_target": llm_target, + "created_at": unix_to_iso(cat), + "updated_at": unix_to_iso(uat), + } + if extra_json: + try: + ex = json.loads(extra_json) + if isinstance(ex, dict): + d["extra"] = ex + except json.JSONDecodeError: + pass + return d + + +def resolve_publish_platform(raw: Optional[str]) -> Optional[str]: + s = (raw or "").strip().lower() + if not s: + return "common" + for key, aliases in PUBLISH_PLATFORM_ALIASES.items(): + if s in {a.lower() for a in aliases}: + return key + return None + + +def _choose_prompt_template(platform: str) -> Optional[Dict[str, Any]]: + init_db() + conn = get_conn() + try: + rows = pr.fetch_active_templates(conn, platform) + if not rows and platform != "common": + rows = pr.fetch_common_fallback(conn) + finally: + conn.close() + return pr.pick_random_template(rows) + + +def _build_prompt_from_template(template_text: str, topic: str, platform: str) -> str: + platform_name = PUBLISH_PLATFORM_CN.get(platform, "通用") + rendered = ( + template_text.replace("{topic}", topic).replace("{platform}", platform).replace("{platform_name}", platform_name) + ) + return rendered.strip() + + +def cmd_add(title: str, body: str, source: str = "manual", llm_target: Optional[str] = None) -> None: + init_db() + title = (title or "").strip() or "未命名" + body = body or "" + ts = now_unix() + conn = get_conn() + try: + new_id = ar.insert_article( + conn, + title=title, + body=body, + content_html=None, + status="draft", + source=source, + account_id=None, + error_msg=None, + llm_target=llm_target, + extra_json=None, + created_at=ts, + updated_at=ts, + ) + conn.commit() + finally: + conn.close() + print(f"✅ 已新增文章 id={new_id} | {title}") + + +def cmd_import_json(path: str) -> None: + init_db() + path = os.path.abspath(path.strip()) + if not os.path.isfile(path): + print(f"❌ 找不到文件:{path}\n请检查路径是否正确、文件是否存在。") + sys.exit(1) + with open(path, encoding="utf-8") as f: + raw = json.load(f) + if isinstance(raw, dict) and "articles" in raw: + items = raw["articles"] + elif isinstance(raw, list): + items = raw + else: + print( + "❌ JSON 格式不对。\n" + "正确格式二选一:① 文件里是数组 [ {\"title\":\"…\",\"body\":\"…\"}, … ]\n" + "② 或对象 {\"articles\": [ … ] },数组里每项至少要有正文(body 或 content)。" + ) + sys.exit(1) + if not items: + print("❌ JSON 里没有可导入的文章条目(数组为空)。") + sys.exit(1) + n = 0 + for i, item in enumerate(items): + if not isinstance(item, dict): + print(f"❌ 第 {i + 1} 条不是 JSON 对象(应为 {{ \"title\":…, \"body\":… }})。") + sys.exit(1) + title = (item.get("title") or item.get("标题") or "").strip() + body = item.get("body") or item.get("content") or item.get("正文") or "" + if isinstance(body, dict): + print(f"❌ 第 {i + 1} 条的 body/content 必须是字符串,不能是别的类型。") + sys.exit(1) + body = str(body) + if not title and not body.strip(): + continue + if not title: + title = f"导入-{i + 1}" + cmd_add(title, body, source="import") + n += 1 + print(f"✅ 批量导入完成,共写入 {n} 篇") + + +def _parse_llm_stdout(stdout: str) -> str: + if "===LLM_START===" in stdout and "===LLM_END===" in stdout: + chunk = stdout.split("===LLM_START===", 1)[1] + chunk = chunk.split("===LLM_END===", 1)[0] + return chunk.strip() + return (stdout or "").strip() + + +def _default_title_from_body(body: str) -> str: + for line in body.splitlines(): + t = line.strip() + if t: + return t[:120] if len(t) > 120 else t + return f"文稿-{now_unix()}" + + +def cmd_generate( + llm_target: str, + topic: str, + publish_platform: str = "common", + title: Optional[str] = None, +) -> None: + llm_target = (llm_target or "").strip() + topic = (topic or "").strip() + publish_platform = (publish_platform or "common").strip().lower() + if not llm_target or not topic: + print( + "❌ 生成参数不完整。\n" + "请使用:python main.py article generate <模型> [发布平台] <主题或关键词>\n" + "示例:python main.py article generate 豆包 搜狐号 RPA降本增效" + ) + sys.exit(1) + template = _choose_prompt_template(publish_platform) + if not template: + print("❌ 提示词模板库为空,请先补充模板后再执行 generate。") + sys.exit(1) + prompt = _build_prompt_from_template(template["template_text"], topic, publish_platform) + script = os.path.join(get_openclaw_root(), "llm-manager", "scripts", "main.py") + if not os.path.isfile(script): + print( + f"❌ 找不到大模型脚本:{script}\n" + "请确认 llm-manager 与 content-manager 在同一上级目录(OpenClaw)下。" + ) + sys.exit(1) + proc = subprocess.run( + [sys.executable, script, "generate", llm_target, prompt], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + out = (proc.stdout or "") + "\n" + (proc.stderr or "") + std = proc.stdout or "" + has_markers = "===LLM_START===" in std and "===LLM_END===" in std + if (proc.returncode != 0 and not has_markers) or ( + proc.returncode == 0 and not has_markers and re.search(r"(?m)^ERROR:", std) + ): + print( + (out.strip() or f"大模型进程退出码 {proc.returncode}") + + "\n❌ 生成失败:请根据上面说明处理(常见:先在「模型管理」添加并登录该平台账号,或配置 API Key)。" + ) + sys.exit(1) + body = _parse_llm_stdout(proc.stdout or out) + if not body: + print( + "❌ 没有从大模型输出里取到正文。\n" + "正常情况输出里应包含 ===LLM_START=== 与 ===LLM_END===;请重试或查看 llm-manager 是否正常打印。" + ) + sys.exit(1) + body = body.strip() + if body.startswith("ERROR:"): + print(out.strip()) + print(f"\n❌ 生成失败,未写入数据库。\n{body}") + sys.exit(1) + final_title = (title or "").strip() or _default_title_from_body(body) + extra_payload = { + "generate_meta": { + "mode": "template", + "topic": topic, + "platform": publish_platform, + "platform_cn": PUBLISH_PLATFORM_CN.get(publish_platform, publish_platform), + "template_id": template["id"], + "template_name": template["name"], + } + } + init_db() + ts = now_unix() + conn = get_conn() + try: + new_id = ar.insert_article( + conn, + title=final_title, + body=body, + content_html=None, + status="draft", + source="llm", + account_id=None, + error_msg=None, + llm_target=llm_target, + extra_json=json.dumps(extra_payload, ensure_ascii=False), + created_at=ts, + updated_at=ts, + ) + pr.insert_usage(conn, int(template["id"]), llm_target, publish_platform, topic, int(new_id)) + conn.commit() + finally: + conn.close() + print( + f"✅ 已写入 LLM 文稿 id={new_id} | {final_title}\n" + f" 模板:{template['name']} (id={template['id']}) | 平台:{PUBLISH_PLATFORM_CN.get(publish_platform, publish_platform)} | 主题:{topic}" + ) + + +def cmd_prompt_list(platform: Optional[str] = None, limit: int = 30) -> None: + init_db() + if limit <= 0: + limit = 30 + key = resolve_publish_platform(platform) if platform else None + if platform and not key: + print(f"❌ 不支持的平台:{platform}") + print("支持:通用 / 搜狐号 / 头条号 / 公众号") + sys.exit(1) + conn = get_conn() + try: + rows = pr.list_templates(conn, key, limit) + finally: + conn.close() + if not rows: + print("暂无提示词模板") + return + sep_line = "_" * 39 + for idx, (rid, p, name, active, uat) in enumerate(rows): + print(f"id:{rid}") + print(f"platform:{p}") + print(f"platform_cn:{PUBLISH_PLATFORM_CN.get(p, p)}") + print(f"name:{name}") + print(f"is_active:{int(active)}") + print(f"updated_at:{unix_to_iso(uat) or ''}") + if idx != len(rows) - 1: + print(sep_line) + print() + + +def cmd_save(article_id: str, title: str, content: str) -> None: + init_db() + ts = now_unix() + conn = get_conn() + try: + if article_id.isdigit(): + aid = int(article_id) + if ar.exists_id(conn, aid): + ar.update_article_body(conn, aid, title, content, ts) + conn.commit() + print(f"✅ 已更新 id={aid} | {title}") + return + new_id = ar.insert_article( + conn, + title=title, + body=content, + content_html=None, + status="draft", + source="manual", + account_id=None, + error_msg=None, + llm_target=None, + extra_json=None, + created_at=ts, + updated_at=ts, + ) + conn.commit() + print(f"✅ 已新建 id={new_id} | {title}") + finally: + conn.close() + + +def cmd_get(article_id: str) -> None: + init_db() + if not str(article_id).strip().isdigit(): + print("❌ 文章 id 必须是纯数字(整数)。请先 article list 查看最左一列编号。") + sys.exit(1) + aid = int(article_id) + conn = get_conn() + try: + row = ar.fetch_by_id(conn, aid) + finally: + conn.close() + if not row: + print("❌ 没有这篇文章:该 id 在库里不存在。请先执行 article list 核对编号。") + sys.exit(1) + print(json.dumps(_row_to_public_dict(row), ensure_ascii=False)) + + +def cmd_list(limit: int = 10, max_chars: int = 50) -> None: + init_db() + conn = get_conn() + try: + rows = ar.list_recent(conn, limit) + finally: + conn.close() + if not rows: + print("暂无文章") + return + + def maybe_truncate(text: str) -> str: + if not text: + return "" + if len(text) > max_chars: + return text[:max_chars] + "..." + return text + + sep_line = "_" * 39 + for idx, r in enumerate(rows): + ( + rid, + title, + body, + content_html, + status, + source, + account_id, + error_msg, + llm_target, + extra_json, + created_at, + updated_at, + ) = r + content = content_html if content_html else (body or "") + + print(f"id:{rid}") + print(f"title:{title or ''}") + print("body:") + print(maybe_truncate(body or "")) + print("content:") + print(maybe_truncate(content or "")) + print(f"status:{status or ''}") + print(f"source:{source or ''}") + print(f"account_id:{account_id or ''}") + print(f"error_msg:{error_msg or ''}") + print(f"llm_target:{llm_target or ''}") + print(f"extra_json:{extra_json or ''}") + print(f"created_at:{unix_to_iso(created_at) or ''}") + print(f"updated_at:{unix_to_iso(updated_at) or ''}") + + if idx != len(rows) - 1: + print(sep_line) + print() + + +def cmd_delete(article_id: str) -> None: + init_db() + if not str(article_id).strip().isdigit(): + print("❌ 文章 id 必须是纯数字。请先 article list 查看。") + sys.exit(1) + aid = int(article_id) + conn = get_conn() + try: + n = ar.delete_by_id(conn, aid) + if n == 0: + print("❌ 没有 id 为 {} 的文章,无法删除。".format(aid)) + sys.exit(1) + conn.commit() + finally: + conn.close() + print(f"✅ 已删除 id={aid}") + + +def cmd_feedback( + article_id: str, + status: str, + account_id: Optional[str] = None, + error_msg: Optional[str] = None, +) -> None: + init_db() + if not str(article_id).strip().isdigit(): + print("❌ 文章 id 必须是纯数字。") + sys.exit(1) + aid = int(article_id) + ts = now_unix() + conn = get_conn() + try: + if not ar.exists_id(conn, aid): + print("❌ 没有 id 为 {} 的文章,无法回写状态。".format(aid)) + sys.exit(1) + ar.update_feedback(conn, aid, status, account_id, error_msg, ts) + conn.commit() + finally: + conn.close() + print("✅ 状态已更新") diff --git a/content-manager/content_manager/services/file_store.py b/content-manager/content_manager/services/file_store.py new file mode 100644 index 0000000..5d8d6c9 --- /dev/null +++ b/content-manager/content_manager/services/file_store.py @@ -0,0 +1,50 @@ +"""媒体文件落盘:相对技能数据目录的路径约定。""" + +from __future__ import annotations + +import os +import shutil +from typing import Tuple + + +def media_subdir(kind: str, media_id: int) -> str: + """kind: images | videos""" + return f"{kind}/{media_id}" + + +def original_basename(src_path: str) -> str: + ext = os.path.splitext(src_path)[1] + return f"original{ext if ext else ''}" + + +def copy_into_skill_data( + skill_data_dir: str, + kind: str, + media_id: int, + src_path: str, +) -> Tuple[str, str]: + """ + 将源文件复制到 {skill_data_dir}/{kind}/{id}/original.ext + 返回 (relative_path, absolute_dest_path) + """ + sub = media_subdir(kind, media_id) + dest_dir = os.path.join(skill_data_dir, sub.replace("/", os.sep)) + os.makedirs(dest_dir, exist_ok=True) + base = original_basename(src_path) + abs_dest = os.path.join(dest_dir, base) + shutil.copy2(src_path, abs_dest) + rel = f"{kind}/{media_id}/{base}".replace("\\", "/") + return rel, abs_dest + + +def remove_files_for_relative_path(skill_data_dir: str, relative_file_path: str) -> None: + """删除 relative_file_path 所在目录(整 id 目录)。""" + rel = (relative_file_path or "").strip().replace("\\", "/") + if not rel or "/" not in rel: + return + parts = rel.split("/") + if len(parts) < 2: + return + id_dir = os.path.join(skill_data_dir, parts[0], parts[1]) + if os.path.isdir(id_dir): + shutil.rmtree(id_dir, ignore_errors=True) diff --git a/content-manager/content_manager/services/image_service.py b/content-manager/content_manager/services/image_service.py new file mode 100644 index 0000000..17fd928 --- /dev/null +++ b/content-manager/content_manager/services/image_service.py @@ -0,0 +1,171 @@ +"""图片:业务规则(文件落盘 + 路径写入 images 表)。""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Dict, Optional + +from content_manager.config import get_skill_data_dir, resolve_stored_path +from content_manager.db import images_repository as ir +from content_manager.db.connection import get_conn, init_db +from content_manager.services import file_store +from content_manager.util.timeutil import now_unix, unix_to_iso + + +def _row_to_public_dict(row: tuple) -> Dict[str, Any]: + rid, file_path, title, status, source, account_id, error_msg, extra_json, cat, uat = row + abs_path = resolve_stored_path(str(file_path)) + d: Dict[str, Any] = { + "id": int(rid), + "kind": "image", + "file_path": file_path, + "absolute_path": abs_path, + "title": title, + "status": status or "draft", + "source": source or "manual", + "account_id": account_id, + "error_msg": error_msg, + "created_at": unix_to_iso(cat), + "updated_at": unix_to_iso(uat), + } + if extra_json: + try: + ex = json.loads(extra_json) + if isinstance(ex, dict): + d["extra"] = ex + except json.JSONDecodeError: + pass + return d + + +def cmd_add(src_file: str, title: Optional[str] = None, source: str = "manual") -> None: + init_db() + src_file = os.path.abspath(src_file.strip()) + if not os.path.isfile(src_file): + print(f"❌ 找不到文件:{src_file}") + sys.exit(1) + skill_data = get_skill_data_dir() + ts = now_unix() + conn = get_conn() + try: + new_id = ir.insert_row( + conn, + file_path="", + title=(title or "").strip() or None, + status="draft", + source=source, + account_id=None, + error_msg=None, + extra_json=None, + created_at=ts, + updated_at=ts, + ) + rel, _abs = file_store.copy_into_skill_data(skill_data, "images", new_id, src_file) + ir.update_file_path(conn, new_id, rel, now_unix()) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + print(f"✅ 已新增图片 id={new_id} | 路径:{rel}") + + +def cmd_get(image_id: str) -> None: + init_db() + if not str(image_id).strip().isdigit(): + print("❌ 图片 id 必须是纯数字。请先 image list 查看。") + sys.exit(1) + iid = int(image_id) + conn = get_conn() + try: + row = ir.fetch_by_id(conn, iid) + finally: + conn.close() + if not row: + print("❌ 没有这条图片记录。") + sys.exit(1) + print(json.dumps(_row_to_public_dict(row), ensure_ascii=False)) + + +def cmd_list(limit: int = 20, max_chars: int = 80) -> None: + init_db() + conn = get_conn() + try: + rows = ir.list_recent(conn, limit) + finally: + conn.close() + if not rows: + print("暂无图片") + return + + def trunc(s: str) -> str: + if not s: + return "" + return s if len(s) <= max_chars else s[:max_chars] + "..." + + sep = "_" * 39 + for idx, r in enumerate(rows): + rid, file_path, title, status, source, account_id, error_msg, extra_json, cat, uat = r + print(f"id:{rid}") + print(f"file_path:{trunc(str(file_path or ''))}") + print(f"title:{title or ''}") + print(f"status:{status or ''}") + print(f"source:{source or ''}") + print(f"account_id:{account_id or ''}") + print(f"error_msg:{error_msg or ''}") + print(f"created_at:{unix_to_iso(cat) or ''}") + print(f"updated_at:{unix_to_iso(uat) or ''}") + if idx != len(rows) - 1: + print(sep) + print() + + +def cmd_delete(image_id: str) -> None: + init_db() + if not str(image_id).strip().isdigit(): + print("❌ 图片 id 必须是纯数字。") + sys.exit(1) + iid = int(image_id) + skill_data = get_skill_data_dir() + conn = get_conn() + try: + row = ir.fetch_by_id(conn, iid) + if not row: + print("❌ 没有 id 为 {} 的图片记录。".format(iid)) + sys.exit(1) + rel = row[1] + n = ir.delete_by_id(conn, iid) + if n == 0: + sys.exit(1) + conn.commit() + finally: + conn.close() + file_store.remove_files_for_relative_path(skill_data, str(rel)) + print(f"✅ 已删除图片 id={iid}") + + +def cmd_feedback( + image_id: str, + status: str, + account_id: Optional[str] = None, + error_msg: Optional[str] = None, +) -> None: + init_db() + if not str(image_id).strip().isdigit(): + print("❌ 图片 id 必须是纯数字。") + sys.exit(1) + iid = int(image_id) + ts = now_unix() + conn = get_conn() + try: + if ir.fetch_by_id(conn, iid) is None: + print("❌ 没有 id 为 {} 的图片记录。".format(iid)) + sys.exit(1) + ir.update_feedback(conn, iid, status, account_id, error_msg, ts) + conn.commit() + finally: + conn.close() + print("✅ 状态已更新") diff --git a/content-manager/content_manager/services/video_service.py b/content-manager/content_manager/services/video_service.py new file mode 100644 index 0000000..8d6d475 --- /dev/null +++ b/content-manager/content_manager/services/video_service.py @@ -0,0 +1,179 @@ +"""视频:业务规则(文件落盘 + 路径写入 videos 表)。""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Dict, Optional + +from content_manager.config import get_skill_data_dir, resolve_stored_path +from content_manager.db import videos_repository as vr +from content_manager.db.connection import get_conn, init_db +from content_manager.services import file_store +from content_manager.util.timeutil import now_unix, unix_to_iso + + +def _row_to_public_dict(row: tuple) -> Dict[str, Any]: + rid, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, cat, uat = row + abs_path = resolve_stored_path(str(file_path)) + d: Dict[str, Any] = { + "id": int(rid), + "kind": "video", + "file_path": file_path, + "absolute_path": abs_path, + "title": title, + "duration_ms": duration_ms, + "status": status or "draft", + "source": source or "manual", + "account_id": account_id, + "error_msg": error_msg, + "created_at": unix_to_iso(cat), + "updated_at": unix_to_iso(uat), + } + if extra_json: + try: + ex = json.loads(extra_json) + if isinstance(ex, dict): + d["extra"] = ex + except json.JSONDecodeError: + pass + return d + + +def cmd_add( + src_file: str, + title: Optional[str] = None, + duration_ms: Optional[int] = None, + source: str = "manual", +) -> None: + init_db() + src_file = os.path.abspath(src_file.strip()) + if not os.path.isfile(src_file): + print(f"❌ 找不到文件:{src_file}") + sys.exit(1) + skill_data = get_skill_data_dir() + ts = now_unix() + conn = get_conn() + try: + new_id = vr.insert_row( + conn, + file_path="", + title=(title or "").strip() or None, + duration_ms=duration_ms, + status="draft", + source=source, + account_id=None, + error_msg=None, + extra_json=None, + created_at=ts, + updated_at=ts, + ) + rel, _abs = file_store.copy_into_skill_data(skill_data, "videos", new_id, src_file) + vr.update_file_path(conn, new_id, rel, now_unix()) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + print(f"✅ 已新增视频 id={new_id} | 路径:{rel}") + + +def cmd_get(video_id: str) -> None: + init_db() + if not str(video_id).strip().isdigit(): + print("❌ 视频 id 必须是纯数字。请先 video list 查看。") + sys.exit(1) + vid = int(video_id) + conn = get_conn() + try: + row = vr.fetch_by_id(conn, vid) + finally: + conn.close() + if not row: + print("❌ 没有这条视频记录。") + sys.exit(1) + print(json.dumps(_row_to_public_dict(row), ensure_ascii=False)) + + +def cmd_list(limit: int = 20, max_chars: int = 80) -> None: + init_db() + conn = get_conn() + try: + rows = vr.list_recent(conn, limit) + finally: + conn.close() + if not rows: + print("暂无视频") + return + + def trunc(s: str) -> str: + if not s: + return "" + return s if len(s) <= max_chars else s[:max_chars] + "..." + + sep = "_" * 39 + for idx, r in enumerate(rows): + rid, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, cat, uat = r + print(f"id:{rid}") + print(f"file_path:{trunc(str(file_path or ''))}") + print(f"title:{title or ''}") + print(f"duration_ms:{duration_ms if duration_ms is not None else ''}") + print(f"status:{status or ''}") + print(f"source:{source or ''}") + print(f"account_id:{account_id or ''}") + print(f"error_msg:{error_msg or ''}") + print(f"created_at:{unix_to_iso(cat) or ''}") + print(f"updated_at:{unix_to_iso(uat) or ''}") + if idx != len(rows) - 1: + print(sep) + print() + + +def cmd_delete(video_id: str) -> None: + init_db() + if not str(video_id).strip().isdigit(): + print("❌ 视频 id 必须是纯数字。") + sys.exit(1) + vid = int(video_id) + skill_data = get_skill_data_dir() + conn = get_conn() + try: + row = vr.fetch_by_id(conn, vid) + if not row: + print("❌ 没有 id 为 {} 的视频记录。".format(vid)) + sys.exit(1) + rel = row[1] + n = vr.delete_by_id(conn, vid) + if n == 0: + sys.exit(1) + conn.commit() + finally: + conn.close() + file_store.remove_files_for_relative_path(skill_data, str(rel)) + print(f"✅ 已删除视频 id={vid}") + + +def cmd_feedback( + video_id: str, + status: str, + account_id: Optional[str] = None, + error_msg: Optional[str] = None, +) -> None: + init_db() + if not str(video_id).strip().isdigit(): + print("❌ 视频 id 必须是纯数字。") + sys.exit(1) + vid = int(video_id) + ts = now_unix() + conn = get_conn() + try: + if vr.fetch_by_id(conn, vid) is None: + print("❌ 没有 id 为 {} 的视频记录。".format(vid)) + sys.exit(1) + vr.update_feedback(conn, vid, status, account_id, error_msg, ts) + conn.commit() + finally: + conn.close() + print("✅ 状态已更新") diff --git a/content-manager/content_manager/util/__init__.py b/content-manager/content_manager/util/__init__.py new file mode 100644 index 0000000..246b986 --- /dev/null +++ b/content-manager/content_manager/util/__init__.py @@ -0,0 +1 @@ +# 工具函数 diff --git a/content-manager/content_manager/util/argparse_zh.py b/content-manager/content_manager/util/argparse_zh.py new file mode 100644 index 0000000..a7c0fb3 --- /dev/null +++ b/content-manager/content_manager/util/argparse_zh.py @@ -0,0 +1,74 @@ +"""argparse 中文错误说明。""" + +from __future__ import annotations + +import argparse +import sys +from typing import List + +from content_manager.constants import CLI_REQUIRED_ZH + + +def split_required_arg_names(raw: str) -> List[str]: + s = raw.replace(" and ", ", ").strip() + parts: List[str] = [] + for chunk in s.split(","): + chunk = chunk.strip() + if not chunk: + continue + idx = chunk.find(" --") + if idx != -1: + left = chunk[:idx].strip() + flag_rest = chunk[idx + 1 :].strip().split() + if left: + parts.append(left) + if flag_rest: + parts.append(flag_rest[0]) + else: + parts.append(chunk) + return [p for p in parts if p] + + +def explain_argparse_error(message: str) -> str: + m = (message or "").strip() + lines: List[str] = ["【命令参数不完整或写错了】请对照下面修改后再执行。"] + if "the following arguments are required:" in m: + raw = m.split("required:", 1)[-1].strip() + parts = split_required_arg_names(raw) + for p in parts: + hint = CLI_REQUIRED_ZH.get(p) + if not hint and p.startswith("--"): + hint = CLI_REQUIRED_ZH.get(p.split()[0], None) + lines.append(f" · {hint or f'还缺这一项:{p}'}") + lines.append(" · 查看全部:python main.py -h") + lines.append(" · 查看分组:python main.py article -h") + return "\n".join(lines) + if "one of the arguments" in m and "required" in m: + lines.append(" · 本命令要求下面几组参数里「必须选其中一组」,不能都不写。") + if "--body-file" in m and "--body" in m: + lines.append(" · 请任选其一:--body-file 某文件路径 或 --body \"正文文字\"") + else: + lines.append(f" · 说明:{m}") + lines.append(" · 查看该子命令:python main.py article add -h") + return "\n".join(lines) + if "unrecognized arguments:" in m: + tail = m.split("unrecognized arguments:", 1)[-1].strip() + lines.append(f" · 多写了不认识的参数:{tail},请删除或检查拼写。") + lines.append(" · 查看用法:python main.py -h") + return "\n".join(lines) + if "invalid choice:" in m: + lines.append(f" · {m}") + return "\n".join(lines) + if "expected one argument" in m: + lines.append(f" · {m}") + lines.append(" · 提示:--xxx 后面必须跟一个值,不要忘记。") + return "\n".join(lines) + lines.append(f" · {m}") + lines.append(" · 查看帮助:python main.py -h") + return "\n".join(lines) + + +class ZhArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + print(explain_argparse_error(message), file=sys.stderr) + self.exit(2) diff --git a/content-manager/content_manager/util/timeutil.py b/content-manager/content_manager/util/timeutil.py new file mode 100644 index 0000000..fe70625 --- /dev/null +++ b/content-manager/content_manager/util/timeutil.py @@ -0,0 +1,36 @@ +"""时间戳工具。""" + +from __future__ import annotations + +import time +from datetime import datetime +from typing import Any, Optional + + +def now_unix() -> int: + return int(time.time()) + + +def unix_to_iso(ts: Optional[int]) -> Optional[str]: + if ts is None: + return None + try: + return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") + except (ValueError, OSError, OverflowError): + return None + + +def parse_ts_to_unix(val: Any) -> Optional[int]: + if val is None: + return None + if isinstance(val, (int, float)): + return int(val) + s = str(val).strip() + if not s: + return None + if s.isdigit(): + return int(s) + try: + return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()) + except ValueError: + return None diff --git a/content-manager/release.ps1 b/content-manager/release.ps1 new file mode 100644 index 0000000..0e7c8c5 --- /dev/null +++ b/content-manager/release.ps1 @@ -0,0 +1,220 @@ +<# +.SYNOPSIS + One-command release script for skill repos. + +.DESCRIPTION + - Optional auto-commit + - Push current branch + - Auto-increment semantic tag (vX.Y.Z) + - Create & push tag + - Fail fast on unsafe states + +.EXAMPLES + # Safe mode (recommended): requires clean working tree + .\release.ps1 + + # Auto commit tracked/untracked changes then release + .\release.ps1 -AutoCommit -CommitMessage "chore: update skill config" + + # Dry run (show what would happen) + .\release.ps1 -DryRun + + # Custom tag prefix + .\release.ps1 -Prefix "v" -Message "正式发布" + +.NOTES + Requires: git, PowerShell 5+ +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) +if (Test-Path $sharedScript) { + & $sharedScript @PSBoundParameters + exit $LASTEXITCODE +} + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string]$Args) + Write-Host ">> git $Args" -ForegroundColor DarkGray + & cmd /c "git $Args" + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } +} + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string]$Args) + $output = & cmd /c "git $Args" 2>$null + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } + return @($output) +} + +function Test-Repo { + & git rev-parse --is-inside-work-tree *> $null + return ($LASTEXITCODE -eq 0) +} + +function Get-CurrentBranch { + $b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim() + return $b +} + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput "status --porcelain") + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput "tag --list" + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + & git status --short + throw "Abort: dirty working tree." + } + + # 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启 + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git "add -A" + Invoke-Git "commit -m `"$commitMsg`"" +} + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + Invoke-Git "fetch --tags --prune origin" + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null) + $hasUpstream = ($LASTEXITCODE -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git "push" + } else { + Invoke-Git "push -u origin $branch" + } + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + + $existing = @(Get-GitOutput "tag --list `"$nextTag`"") + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-Git "tag -a $nextTag -m `"$Message`"" + Invoke-Git "push origin $nextTag" + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/content-manager/scripts/main.py b/content-manager/scripts/main.py new file mode 100644 index 0000000..bfd0e6d --- /dev/null +++ b/content-manager/scripts/main.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""content-manager CLI 入口;逻辑在 content_manager 包内分层实现。""" + +from __future__ import annotations + +import os +import sys + +# Windows GBK 下控制台 UTF-8 输出 +if sys.platform == "win32": + import io + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +_SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _SKILL_ROOT not in sys.path: + sys.path.insert(0, _SKILL_ROOT) + +from content_manager.cli.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml b/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml new file mode 100644 index 0000000..0e55b3f --- /dev/null +++ b/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml @@ -0,0 +1,139 @@ +name: Reusable Skill Release + +on: + workflow_call: + inputs: + artifact_platform: + required: false + type: string + default: windows + pyarmor_platform: + required: false + type: string + default: windows.x86_64 + upload_url: + required: false + type: string + default: https://jc2009.com/api/upload + sync_url: + required: false + type: string + default: https://jc2009.com/api/skill/update + prune_url: + required: false + type: string + default: https://jc2009.com/api/artifacts/prune-old-versions + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + env: + ARTIFACT_PLATFORM: ${{ inputs.artifact_platform }} + PYARMOR_PLATFORM: ${{ inputs.pyarmor_platform }} + PIP_BREAK_SYSTEM_PACKAGES: "1" + steps: + - uses: http://120.25.191.12:3000/admin/actions-checkout@v4 + + - name: Setup Tools + run: pip install "pyarmor>=8.5" requests python-frontmatter --break-system-packages -i https://pypi.tuna.tsinghua.edu.cn/simple + + - name: Encrypt Source Code + run: | + mkdir -p dist/package + pyarmor gen --platform "${PYARMOR_PLATFORM}" -O dist/package scripts/*.py + cp SKILL.md dist/package/ + + - name: Parse Metadata and Pack + id: build_task + run: | + python -c " + import frontmatter, os, json, shutil + post = frontmatter.load('SKILL.md') + metadata = dict(post.metadata or {}) + metadata['readme_md'] = (post.content or '').strip() + 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: + raise Exception('SKILL.md 缺少 slug/name') + ref_name = (os.environ.get('GITHUB_REF_NAME') or '').strip() + if not ref_name.startswith('v'): + raise Exception(f'非法标签: {ref_name}') + version = ref_name.lstrip('v') + metadata['version'] = version + artifact_platform = (os.environ.get('ARTIFACT_PLATFORM') or 'windows').strip() + zip_base = f'{slug}-{artifact_platform}' + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'slug={slug}\n') + f.write(f'version={version}\n') + f.write(f'zip_base={zip_base}\n') + f.write(f'artifact_platform={artifact_platform}\n') + f.write(f'metadata={json.dumps(metadata, ensure_ascii=False)}\n') + shutil.make_archive(zip_base, 'zip', 'dist/package') + " + + - name: Sync Database + env: + METADATA_JSON: ${{ steps.build_task.outputs.metadata }} + SYNC_URL: ${{ inputs.sync_url }} + run: | + python -c " + import requests, json, os + metadata = json.loads(os.environ['METADATA_JSON']) + res = requests.post(os.environ['SYNC_URL'], json=metadata) + if res.status_code != 200: + exit(1) + body = res.json() + if body.get('code') != 200: + exit(1) + " + + - name: Upload Encrypted ZIP + env: + SLUG: ${{ steps.build_task.outputs.slug }} + VERSION: ${{ steps.build_task.outputs.version }} + ZIP_BASE: ${{ steps.build_task.outputs.zip_base }} + ARTIFACT_PLATFORM: ${{ steps.build_task.outputs.artifact_platform }} + UPLOAD_URL: ${{ inputs.upload_url }} + run: | + python -c " + import requests, os + slug = os.environ['SLUG'] + version = os.environ['VERSION'] + zip_path = f\"{os.environ['ZIP_BASE']}.zip\" + payload = { + 'plugin_name': slug, + 'version': version, + 'artifact_type': 'skill', + 'artifact_platform': os.environ.get('ARTIFACT_PLATFORM', 'windows'), + } + filename = os.path.basename(zip_path) + with open(zip_path, 'rb') as f: + res = requests.post(os.environ['UPLOAD_URL'], data=payload, files={'file': (filename, f)}) + if res.status_code != 200: + exit(1) + body = res.json() + if body.get('code') != 200: + exit(1) + " + + - name: Prune Old Versions + env: + SLUG: ${{ steps.build_task.outputs.slug }} + VERSION: ${{ steps.build_task.outputs.version }} + PRUNE_URL: ${{ inputs.prune_url }} + run: | + python -c " + import requests, os + payload = { + 'name': os.environ['SLUG'], + 'artifact_type': 'skill', + 'keep_count': 1, + 'protect_version': os.environ['VERSION'] + } + res = requests.post(os.environ['PRUNE_URL'], json=payload) + if res.status_code != 200: + exit(1) + body = res.json() + if body.get('code') != 200: + exit(1) + " diff --git a/jiangchang-platform-kit/README.md b/jiangchang-platform-kit/README.md new file mode 100644 index 0000000..6d5b553 --- /dev/null +++ b/jiangchang-platform-kit/README.md @@ -0,0 +1,7 @@ +# jiangchang-platform-kit + +Shared platform components for Jiangchang skills: + +- `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. diff --git a/jiangchang-platform-kit/examples/flask_skill_update_or_create.py b/jiangchang-platform-kit/examples/flask_skill_update_or_create.py new file mode 100644 index 0000000..95dda0e --- /dev/null +++ b/jiangchang-platform-kit/examples/flask_skill_update_or_create.py @@ -0,0 +1,84 @@ +# 将此路由合并进你的 skill 蓝图。 +# CI 不再写入 skill_type / monthly_price / yearly_price,避免每次发布覆盖后台手工配置。 +# +# 要求:SkillModel.update_or_create 在「更新」时对 data 中未出现的列应保留数据库原值; +# 若当前是整行覆盖,请在 Model 层改为按字段合并或白名单更新。 +# 若表对这三列 NOT NULL 且无默认值,仅在「首次插入」时在 Model 内写死默认即可。 + +import json +import re +from datetime import datetime + +from flask import jsonify, request + +# from your_app import skill_bp, SkillModel # 按你项目实际导入 + + +@skill_bp.route("/api/skill/update", methods=["POST"]) +def update_or_create_skill(): + """CI/CD 自动化注册接口(可选携带 readme_md = SKILL.md 正文 Markdown)""" + try: + data = request.get_json(silent=True) or {} + if not data: + return jsonify({"code": 400, "msg": "请求体为空", "data": None}), 400 + + openclaw_meta = data.get("metadata", {}).get("openclaw", {}) + + slug = ( + (data.get("slug") or "").strip() + or (openclaw_meta.get("slug") or "").strip() + or (data.get("name") or "").strip() + ) + name = (data.get("name") or slug).strip() + version = str(data.get("version") or "").strip() + category = (openclaw_meta.get("category") or "").strip() + + if not all([slug, name, version, category]): + return jsonify( + { + "code": 400, + "msg": "元数据不完整(需包含 slug/name/version/category)", + "data": None, + } + ), 400 + + slug = slug.lower() + + if not re.match(r"^[a-z0-9-]+$", slug): + return jsonify( + { + "code": 400, + "msg": "slug 格式非法,仅允许小写字母、数字和中划线", + "data": None, + } + ), 400 + + skill_data = { + "slug": slug, + "name": name, + "description": data.get("description"), + "version": version, + "category": category, + "developer_name": data.get("author", "匠厂开发者"), + "tags": json.dumps(data.get("tags", []), ensure_ascii=False), + "status": 2, + "updated_at": datetime.now(), + } + + if "readme_md" in data: + rm = data.get("readme_md") + skill_data["readme_md"] = "" if rm is None else str(rm) + + success = SkillModel.update_or_create(slug=slug, data=skill_data) + if success: + return jsonify( + { + "code": 200, + "msg": "注册成功", + "data": {"slug": slug, "name": name, "version": version}, + } + ), 200 + return jsonify({"code": 500, "msg": "数据持久化失败", "data": None}), 500 + + except Exception as e: + return jsonify({"code": 500, "msg": str(e), "data": None}), 500 diff --git a/jiangchang-platform-kit/examples/workflows/use-reusable-release-skill.yaml b/jiangchang-platform-kit/examples/workflows/use-reusable-release-skill.yaml new file mode 100644 index 0000000..016d6b6 --- /dev/null +++ b/jiangchang-platform-kit/examples/workflows/use-reusable-release-skill.yaml @@ -0,0 +1,12 @@ +name: Skill Release +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/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 diff --git a/jiangchang-platform-kit/sdk/jiangchang_skill_core/__init__.py b/jiangchang-platform-kit/sdk/jiangchang_skill_core/__init__.py new file mode 100644 index 0000000..c49320b --- /dev/null +++ b/jiangchang-platform-kit/sdk/jiangchang_skill_core/__init__.py @@ -0,0 +1,9 @@ +from .client import EntitlementClient +from .guard import enforce_entitlement +from .models import EntitlementResult + +__all__ = [ + "EntitlementClient", + "EntitlementResult", + "enforce_entitlement", +] diff --git a/jiangchang-platform-kit/sdk/jiangchang_skill_core/client.py b/jiangchang-platform-kit/sdk/jiangchang_skill_core/client.py new file mode 100644 index 0000000..6860557 --- /dev/null +++ b/jiangchang-platform-kit/sdk/jiangchang_skill_core/client.py @@ -0,0 +1,63 @@ +import os +from typing import Any + +import requests + +from .errors import EntitlementServiceError +from .models import EntitlementResult + + +class EntitlementClient: + def __init__( + self, + base_url: str | None = None, + api_key: str | None = None, + timeout_seconds: int | None = None, + ) -> None: + self.base_url = (base_url or os.getenv("JIANGCHANG_AUTH_BASE_URL", "")).rstrip("/") + self.api_key = api_key or os.getenv("JIANGCHANG_AUTH_API_KEY", "") + self.timeout_seconds = timeout_seconds or int( + os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS", "5") + ) + if not self.base_url: + raise EntitlementServiceError("missing JIANGCHANG_AUTH_BASE_URL") + + def check_entitlement( + self, + user_id: str, + skill_slug: str, + trace_id: str = "", + context: dict[str, Any] | None = None, + ) -> EntitlementResult: + url = f"{self.base_url}/api/entitlements/check" + payload = { + "user_id": user_id, + "skill_slug": skill_slug, + "trace_id": trace_id, + "context": context or {}, + } + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + try: + res = requests.post( + url, + json=payload, + headers=headers, + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise EntitlementServiceError(f"entitlement request failed: {exc}") from exc + + if res.status_code != 200: + raise EntitlementServiceError(f"entitlement http status {res.status_code}") + try: + body = res.json() + except ValueError as exc: + raise EntitlementServiceError("entitlement response is not json") from exc + + data = body.get("data") or {} + allow = bool(data.get("allow", False)) + reason = str(data.get("reason") or body.get("msg") or "") + expire_at = str(data.get("expire_at") or "") + return EntitlementResult(allow=allow, reason=reason, expire_at=expire_at, raw=body) diff --git a/jiangchang-platform-kit/sdk/jiangchang_skill_core/errors.py b/jiangchang-platform-kit/sdk/jiangchang_skill_core/errors.py new file mode 100644 index 0000000..7aa9b82 --- /dev/null +++ b/jiangchang-platform-kit/sdk/jiangchang_skill_core/errors.py @@ -0,0 +1,10 @@ +class EntitlementError(Exception): + pass + + +class EntitlementDeniedError(EntitlementError): + pass + + +class EntitlementServiceError(EntitlementError): + pass diff --git a/jiangchang-platform-kit/sdk/jiangchang_skill_core/guard.py b/jiangchang-platform-kit/sdk/jiangchang_skill_core/guard.py new file mode 100644 index 0000000..365d745 --- /dev/null +++ b/jiangchang-platform-kit/sdk/jiangchang_skill_core/guard.py @@ -0,0 +1,22 @@ +from .client import EntitlementClient +from .errors import EntitlementDeniedError +from .models import EntitlementResult + + +def enforce_entitlement( + user_id: str, + skill_slug: str, + trace_id: str = "", + context: dict | None = None, + client: EntitlementClient | None = None, +) -> EntitlementResult: + c = client or EntitlementClient() + result = c.check_entitlement( + user_id=user_id, + skill_slug=skill_slug, + trace_id=trace_id, + context=context or {}, + ) + if not result.allow: + raise EntitlementDeniedError(result.reason or "skill not purchased or expired") + return result diff --git a/jiangchang-platform-kit/sdk/jiangchang_skill_core/models.py b/jiangchang-platform-kit/sdk/jiangchang_skill_core/models.py new file mode 100644 index 0000000..473a70d --- /dev/null +++ b/jiangchang-platform-kit/sdk/jiangchang_skill_core/models.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class EntitlementResult: + allow: bool + reason: str = "" + expire_at: str = "" + raw: dict[str, Any] | None = None diff --git a/jiangchang-platform-kit/sdk/pyproject.toml b/jiangchang-platform-kit/sdk/pyproject.toml new file mode 100644 index 0000000..854fc10 --- /dev/null +++ b/jiangchang-platform-kit/sdk/pyproject.toml @@ -0,0 +1,19 @@ +[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*"] diff --git a/jiangchang-platform-kit/tools/release.ps1 b/jiangchang-platform-kit/tools/release.ps1 new file mode 100644 index 0000000..9a3aa41 --- /dev/null +++ b/jiangchang-platform-kit/tools/release.ps1 @@ -0,0 +1,216 @@ +<# +.SYNOPSIS + One-command release script for skill repos. + +.DESCRIPTION + - Optional auto-commit + - Push current branch + - Auto-increment semantic tag (vX.Y.Z) + - Create & push tag + - Fail fast on unsafe states + +.EXAMPLES + # Safe mode (recommended): requires clean working tree + .\release.ps1 + + # Auto commit tracked/untracked changes then release + .\release.ps1 -AutoCommit -CommitMessage "chore: update skill config" + + # Dry run (show what would happen) + .\release.ps1 -DryRun + + # Custom tag prefix + .\release.ps1 -Prefix "v" -Message "正式发布" + +.NOTES + Requires: git, PowerShell 5+ +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string]$Args) + Write-Host ">> git $Args" -ForegroundColor DarkGray + & cmd /c "git $Args" + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } +} + + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string]$Args) + $output = & cmd /c "git $Args" 2>$null + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } + return @($output) +} + +function Test-Repo { + & git rev-parse --is-inside-work-tree *> $null + return ($LASTEXITCODE -eq 0) +} + +function Get-CurrentBranch { + $b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim() + return $b +} + + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput "status --porcelain") + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput "tag --list" + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + & git status --short + throw "Abort: dirty working tree." + } + + # 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启 + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git "add -A" + Invoke-Git "commit -m `"$commitMsg`"" +} + + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + Invoke-Git "fetch --tags --prune origin" + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null) + $hasUpstream = ($LASTEXITCODE -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git "push" + } else { + Invoke-Git "push -u origin $branch" + } + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + + $existing = @(Get-GitOutput "tag --list `"$nextTag`"") + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-Git "tag -a $nextTag -m `"$Message`"" + Invoke-Git "push origin $nextTag" + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/llm-manager/.github/workflows/release_skill.yaml b/llm-manager/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..21617a6 --- /dev/null +++ b/llm-manager/.github/workflows/release_skill.yaml @@ -0,0 +1,11 @@ +name: 技能自动化发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/llm-manager/SKILL.md b/llm-manager/SKILL.md new file mode 100644 index 0000000..bba42fe --- /dev/null +++ b/llm-manager/SKILL.md @@ -0,0 +1,119 @@ +--- +name: 大模型管理器 +description: 统一管理 AI 大模型的调用方式。优先通过账号网页版(免费)访问,无可用网页账号时自动降级到 API Key(付费)。支持豆包、DeepSeek、通义千问、Kimi、文心一言、腾讯元宝。 +version: 1.0.3 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: llm-manager + emoji: "🧠" + category: "通用" + dependencies: + required: + - account-manager + optional: + - openai # pip install openai(仅 API Key 模式需要) + auto_install: false +allowed-tools: + - bash +disable: false +--- + +# 大模型管理器 + +## 使用方式 + +### 生成内容(核心命令) + +```bash +# 指定平台名(自动选择模式:网页优先,API Key 备用) +python3 {baseDir}/scripts/main.py generate kimi "帮我写一篇关于AI发展的文章" +python3 {baseDir}/scripts/main.py generate deepseek "帮我写一篇关于AI发展的文章" +python3 {baseDir}/scripts/main.py generate doubao "帮我写一篇关于AI发展的文章" +python3 {baseDir}/scripts/main.py generate qianwen "帮我写一篇关于AI发展的文章" +python3 {baseDir}/scripts/main.py generate yiyan "帮我写一篇关于AI发展的文章" +python3 {baseDir}/scripts/main.py generate yuanbao "帮我写一篇关于AI发展的文章" + +# 指定 account_id(强制使用该账号网页模式) +python3 {baseDir}/scripts/main.py generate 3 "帮我写一篇关于AI发展的文章" +``` + +### 管理 API Key(可选,付费模式) + +```bash +# 添加 API Key +python3 {baseDir}/scripts/main.py key add deepseek "sk-xxx" +python3 {baseDir}/scripts/main.py key add kimi "sk-xxx" --model moonshot-v1-8k +python3 {baseDir}/scripts/main.py key add doubao "xxx" --model ep-xxx # 豆包须填 endpoint_id + +# 查看 +python3 {baseDir}/scripts/main.py key list +python3 {baseDir}/scripts/main.py key list deepseek + +# 删除 +python3 {baseDir}/scripts/main.py key del <key_id> +``` + +### 健康检查 + +```bash +python3 {baseDir}/scripts/main.py health +python3 {baseDir}/scripts/main.py version +``` + +## 调用模式选择逻辑 + +``` +generate <target> "<prompt>" + │ + ├─ target 是数字(account_id) + │ └─ 强制网页模式(需该账号已在 account-manager 登录) + │ + └─ target 是平台名 + ├─ 1. 查 account-manager:有 login_status=1 的账号? → 网页模式(免费) + ├─ 2. 查本地 llm_keys:有 is_active=1 的 Key? → API Key 模式(付费) + └─ 3. 都没有 → 报错,给出两种凭据的配置指引 +``` + +## 使用网页模式的前提 + +网页模式依赖 **account-manager** 管理的账号和 Chrome Profile,使用前需完成: + +```bash +# 1. 在 account-manager 中添加对应平台账号 +python3 {accountManagerDir}/scripts/main.py add "Kimi" "手机号" + +# 2. 登录(打开浏览器,手动完成登录后自动写入状态) +python3 {accountManagerDir}/scripts/main.py login <id> +``` + +## 环境变量 + +| 变量 | 说明 | 默认值 | +|---|---|---| +| `JIANGCHANG_DATA_ROOT` | 数据根目录(与 account-manager 一致) | Win: `D:\jiangchang-data` | +| `JIANGCHANG_USER_ID` | 用户/工作区 ID | `_anon` | + +API Key 存储路径:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/llm-manager/llm-manager.db` +网页账号:通过 `account-manager` 子命令 `pick-logged-in` 查询,不直接读其数据库。 + +## 支持的平台 + +| 平台 | Slug | 中文别名 | 网页模式 | API 模式 | +|---|---|---|---|---| +| 豆包 | doubao | 豆包 | ✓ | ✓(火山方舟,需 ep-xxx) | +| DeepSeek | deepseek | 深度求索 | ✓ | ✓ | +| 通义千问 | qianwen | 通义、千问 | ✓ | ✓ | +| Kimi | kimi | 月之暗面 | ✓ | ✓ | +| 文心一言 | yiyan | 文心、一言 | ✓ | ✓ | +| 腾讯元宝 | yuanbao | 元宝 | ✓ | ✗(暂无公开 API) | + +## 输出格式 + +generate 命令输出固定格式,方便下游 Skill(如 sohu-publisher)精确提取: + +``` +===LLM_START=== +(生成的内容) +===LLM_END=== +``` diff --git a/llm-manager/release.ps1 b/llm-manager/release.ps1 new file mode 100644 index 0000000..0e7c8c5 --- /dev/null +++ b/llm-manager/release.ps1 @@ -0,0 +1,220 @@ +<# +.SYNOPSIS + One-command release script for skill repos. + +.DESCRIPTION + - Optional auto-commit + - Push current branch + - Auto-increment semantic tag (vX.Y.Z) + - Create & push tag + - Fail fast on unsafe states + +.EXAMPLES + # Safe mode (recommended): requires clean working tree + .\release.ps1 + + # Auto commit tracked/untracked changes then release + .\release.ps1 -AutoCommit -CommitMessage "chore: update skill config" + + # Dry run (show what would happen) + .\release.ps1 -DryRun + + # Custom tag prefix + .\release.ps1 -Prefix "v" -Message "正式发布" + +.NOTES + Requires: git, PowerShell 5+ +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) +if (Test-Path $sharedScript) { + & $sharedScript @PSBoundParameters + exit $LASTEXITCODE +} + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string]$Args) + Write-Host ">> git $Args" -ForegroundColor DarkGray + & cmd /c "git $Args" + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } +} + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string]$Args) + $output = & cmd /c "git $Args" 2>$null + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } + return @($output) +} + +function Test-Repo { + & git rev-parse --is-inside-work-tree *> $null + return ($LASTEXITCODE -eq 0) +} + +function Get-CurrentBranch { + $b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim() + return $b +} + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput "status --porcelain") + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput "tag --list" + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + & git status --short + throw "Abort: dirty working tree." + } + + # 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启 + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git "add -A" + Invoke-Git "commit -m `"$commitMsg`"" +} + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + Invoke-Git "fetch --tags --prune origin" + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null) + $hasUpstream = ($LASTEXITCODE -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git "push" + } else { + Invoke-Git "push -u origin $branch" + } + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + + $existing = @(Get-GitOutput "tag --list `"$nextTag`"") + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-Git "tag -a $nextTag -m `"$Message`"" + Invoke-Git "push origin $nextTag" + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/llm-manager/scripts/db.py b/llm-manager/scripts/db.py new file mode 100644 index 0000000..1892e13 --- /dev/null +++ b/llm-manager/scripts/db.py @@ -0,0 +1,292 @@ +""" +llm-manager 本地数据库: +- llm_keys: API Key 记录 +- llm_web_accounts: 网页模式账号关联记录(账号主数据仍由 account-manager 管理) +""" +import os +import sqlite3 +import time +from typing import Optional + +from providers import get_data_root, get_user_id + +SKILL_SLUG = "llm-manager" + +# SQLite 无独立 DATETIME:时间统一存 INTEGER Unix 秒(UTC)。 +LLM_KEYS_TABLE_SQL = """ +CREATE TABLE llm_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 主键(自增) + provider TEXT NOT NULL, -- 平台 slug:doubao/deepseek/qianwen/kimi/yiyan/yuanbao + label TEXT NOT NULL DEFAULT '', -- 用户自定义备注,如「公司Key」 + api_key TEXT NOT NULL, -- API Key 原文 + default_model TEXT, -- 默认模型(doubao 须填 ep-xxx) + is_active INTEGER NOT NULL DEFAULT 1, -- 是否启用:0 停用 1 启用 + last_used_at INTEGER, -- 最近调用时间,Unix 秒;从未用过为 NULL + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +""" + +LLM_WEB_ACCOUNTS_TABLE_SQL = """ +CREATE TABLE llm_web_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + account_id INTEGER NOT NULL, + account_name TEXT NOT NULL DEFAULT '', + login_status INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(provider, account_id) +); +""" + + +def _now_unix() -> int: + return int(time.time()) + + +def get_skill_data_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) + os.makedirs(path, exist_ok=True) + return path + + +def get_db_path() -> str: + return os.path.join(get_skill_data_dir(), f"{SKILL_SLUG}.db") + + +def get_conn(): + return sqlite3.connect(get_db_path()) + + +def init_db(): + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='llm_keys'") + if not cur.fetchone(): + cur.executescript(LLM_KEYS_TABLE_SQL) + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='llm_web_accounts'") + if not cur.fetchone(): + cur.executescript(LLM_WEB_ACCOUNTS_TABLE_SQL) + conn.commit() + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +def add_key(provider: str, api_key: str, model: Optional[str] = None, label: str = "") -> int: + init_db() + now = _now_unix() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO llm_keys (provider, label, api_key, default_model, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, ?, ?) + """, + (provider, label or "", api_key, model, now, now), + ) + new_id = cur.lastrowid + conn.commit() + return new_id + finally: + conn.close() + + +def upsert_web_account(provider: str, account_id: int, account_name: str = "", login_status: int = 1) -> int: + init_db() + now = _now_unix() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO llm_web_accounts (provider, account_id, account_name, login_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, account_id) DO UPDATE SET + account_name = excluded.account_name, + login_status = excluded.login_status, + updated_at = excluded.updated_at + """, + (provider, int(account_id), account_name or "", int(login_status or 0), now, now), + ) + conn.commit() + cur.execute( + "SELECT id FROM llm_web_accounts WHERE provider = ? AND account_id = ?", + (provider, int(account_id)), + ) + row = cur.fetchone() + return int(row[0]) if row else 0 + finally: + conn.close() + + +def list_keys(provider: Optional[str] = None, limit: int = 10) -> list: + init_db() + if not isinstance(limit, int) or limit <= 0: + limit = 10 + conn = get_conn() + try: + cur = conn.cursor() + if provider: + cur.execute( + "SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at " + "FROM llm_keys WHERE provider = ? ORDER BY created_at DESC, id DESC LIMIT ?", + (provider, limit), + ) + else: + cur.execute( + "SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at " + "FROM llm_keys ORDER BY created_at DESC, id DESC LIMIT ?", + (limit,), + ) + rows = cur.fetchall() + finally: + conn.close() + + result = [] + for row in rows: + result.append({ + "id": row[0], + "provider": row[1], + "label": row[2] or "", + "api_key": row[3], + "default_model": row[4] or "", + "is_active": row[5], + "last_used_at": row[6], + "created_at": row[7], + }) + return result + + +def list_web_accounts(provider: Optional[str] = None, limit: int = 10) -> list: + init_db() + if not isinstance(limit, int) or limit <= 0: + limit = 10 + conn = get_conn() + try: + cur = conn.cursor() + if provider: + cur.execute( + "SELECT id, provider, account_id, account_name, login_status, created_at, updated_at " + "FROM llm_web_accounts WHERE provider = ? ORDER BY created_at DESC, id DESC LIMIT ?", + (provider, limit), + ) + else: + cur.execute( + "SELECT id, provider, account_id, account_name, login_status, created_at, updated_at " + "FROM llm_web_accounts ORDER BY created_at DESC, id DESC LIMIT ?", + (limit,), + ) + rows = cur.fetchall() + finally: + conn.close() + + result = [] + for row in rows: + result.append({ + "id": row[0], + "provider": row[1], + "account_id": row[2], + "account_name": row[3] or "", + "login_status": int(row[4] or 0), + "created_at": row[5], + "updated_at": row[6], + }) + return result + + +def get_key_by_id(key_id: int) -> Optional[dict]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at " + "FROM llm_keys WHERE id = ?", + (key_id,), + ) + row = cur.fetchone() + if not row: + return None + return { + "id": row[0], + "provider": row[1], + "label": row[2] or "", + "api_key": row[3], + "default_model": row[4] or "", + "is_active": row[5], + "last_used_at": row[6], + "created_at": row[7], + } + finally: + conn.close() + + +def delete_key(key_id: int) -> bool: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT id FROM llm_keys WHERE id = ?", (key_id,)) + if not cur.fetchone(): + return False + cur.execute("DELETE FROM llm_keys WHERE id = ?", (key_id,)) + conn.commit() + return True + finally: + conn.close() + + +def find_active_key(provider: str) -> Optional[dict]: + """查找该平台第一个 is_active=1 的 key(按 id 升序)。""" + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, provider, label, api_key, default_model, is_active, last_used_at " + "FROM llm_keys WHERE provider = ? AND is_active = 1 ORDER BY id LIMIT 1", + (provider,), + ) + row = cur.fetchone() + if not row: + return None + return { + "id": row[0], + "provider": row[1], + "label": row[2] or "", + "api_key": row[3], + "default_model": row[4] or "", + "is_active": row[5], + "last_used_at": row[6], + } + finally: + conn.close() + + +def mark_key_used(key_id: int): + now = _now_unix() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "UPDATE llm_keys SET last_used_at = ?, updated_at = ? WHERE id = ?", + (now, now, key_id), + ) + conn.commit() + finally: + conn.close() + + +def _mask_key(api_key: str) -> str: + """展示时打码:前4位 + ... + 后4位。""" + k = api_key or "" + if len(k) <= 8: + return k[:2] + "****" + return k[:4] + "..." + k[-4:] diff --git a/llm-manager/scripts/engines/api_engine.py b/llm-manager/scripts/engines/api_engine.py new file mode 100644 index 0000000..ab146cc --- /dev/null +++ b/llm-manager/scripts/engines/api_engine.py @@ -0,0 +1,28 @@ +""" +OpenAI 兼容 API 引擎:适用于 DeepSeek、通义千问、Kimi、文心一言、豆包(火山方舟)。 +只要平台提供 OpenAI 兼容接口,均可用此引擎,无需 Playwright。 +""" + + +class ApiEngine: + def __init__(self, api_base: str, api_key: str, model: str): + self.api_base = api_base + self.api_key = api_key + self.model = model + + def generate(self, prompt: str) -> str: + try: + from openai import OpenAI + except ImportError: + return "ERROR:缺少依赖:pip install openai" + + try: + client = OpenAI(base_url=self.api_base, api_key=self.api_key) + response = client.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": prompt}], + ) + content = response.choices[0].message.content + return (content or "").strip() + except Exception as e: + return f"ERROR:API调用失败:{e}" diff --git a/llm-manager/scripts/engines/base.py b/llm-manager/scripts/engines/base.py new file mode 100644 index 0000000..de087d7 --- /dev/null +++ b/llm-manager/scripts/engines/base.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from playwright.async_api import Page + + +class BaseEngine(ABC): + def __init__(self, page: Page): + self.page = page + + @abstractmethod + async def generate(self, prompt: str) -> str: + """ + 基于操作页面的具体大模型的生成逻辑。输入文本,抓取并返回文本。 + 返回值:正常结果字符串,或以 "ERROR:" 开头的错误描述。 + """ + pass diff --git a/llm-manager/scripts/engines/deepseek.py b/llm-manager/scripts/engines/deepseek.py new file mode 100644 index 0000000..ad402ce --- /dev/null +++ b/llm-manager/scripts/engines/deepseek.py @@ -0,0 +1,70 @@ +""" +DeepSeek 网页版驱动引擎(chat.deepseek.com)。 + +选择器说明(如页面改版需更新): +- 输入框:#chat-input(textarea) +- 发送按钮:[aria-label="发送消息"] 或 div[class*="send"] > button +- 停止生成按钮:[aria-label="停止生成"](生成中可见,生成结束消失) +- 复制按钮:最后一条回复下的 [aria-label="复制"] +""" +import asyncio +from .base import BaseEngine + + +class DeepSeekEngine(BaseEngine): + async def generate(self, prompt: str) -> str: + await self.page.goto("https://chat.deepseek.com") + await self.page.wait_for_load_state("networkidle") + + # 登录检测:若能找到输入框则已登录 + try: + editor = self.page.locator("textarea#chat-input").first + await editor.wait_for(state="visible", timeout=10000) + except Exception: + return "ERROR:REQUIRE_LOGIN" + + # 输入提示词 + await editor.click() + await self.page.keyboard.insert_text(prompt) + await asyncio.sleep(0.5) + + # 发送(优先点按钮,失败则按 Enter) + sent = False + for sel in ('[aria-label="发送消息"]', 'div[class*="send"] > button', 'button[type="submit"]'): + try: + btn = self.page.locator(sel).first + if await btn.is_visible(timeout=1000): + await btn.click() + sent = True + break + except Exception: + continue + if not sent: + await self.page.keyboard.press("Enter") + + print("💡 [llm-manager/deepseek] 已发送提示词,等待 DeepSeek 生成响应...") + await asyncio.sleep(2) + + # 等待生成完毕:停止按钮消失即为完成,超时 150 秒 + stop_sel = '[aria-label="停止生成"]' + deadline = asyncio.get_event_loop().time() + 150 + while asyncio.get_event_loop().time() < deadline: + try: + visible = await self.page.locator(stop_sel).first.is_visible(timeout=500) + if not visible: + break + except Exception: + break + await asyncio.sleep(2) + + await asyncio.sleep(2) + + # 通过最后一个复制按钮取结果 + try: + copy_btn = self.page.locator('[aria-label="复制"]').last + await copy_btn.click() + await asyncio.sleep(0.5) + result = await self.page.evaluate("navigator.clipboard.readText()") + return result.strip() + except Exception as e: + return f"ERROR:抓取 DeepSeek 内容时异常:{e}" diff --git a/llm-manager/scripts/engines/doubao.py b/llm-manager/scripts/engines/doubao.py new file mode 100644 index 0000000..63fca87 --- /dev/null +++ b/llm-manager/scripts/engines/doubao.py @@ -0,0 +1,222 @@ +""" +豆包网页版驱动(doubao.com/chat/)。 + +生成后不依赖「喇叭瞬时出现/消失」这种不稳定信号。 +而是轮询判断两种输出是否已进入可复制状态: +1) 右侧写作模式:出现「改用对话直接回答」入口并可点右侧复制按钮 +2) 左侧对话模式:出现可点的消息复制按钮(拿最后一条新增回复) +""" + +import asyncio +import time + +from .base import BaseEngine + +_DOUBAO_SPEAKER_PATH_SNIPPET = "M19.8628 9.29346C20.3042" + + +class DoubaoEngine(BaseEngine): + async def generate(self, prompt: str) -> str: + self.page.set_default_timeout(60_000) + await self.page.goto( + "https://www.doubao.com/chat/", + wait_until="domcontentloaded", + timeout=60_000, + ) + + editor = self.page.locator('textarea[data-testid="chat_input_input"]').first + try: + await editor.wait_for(state="visible", timeout=30_000) + except Exception: + return "ERROR:REQUIRE_LOGIN" + + text = (prompt or "").strip() + if not text: + return "ERROR:PROMPT_EMPTY" + + await editor.click() + await editor.fill(text) + await asyncio.sleep(0.2) + + send = self.page.locator( + '#flow-end-msg-send, [data-testid="chat_input_send_button"]' + ).first + try: + await send.wait_for(state="visible", timeout=15_000) + await send.click(timeout=10_000) + except Exception as e: + return f"ERROR:DOUBAO_SEND_FAILED {e}" + + print("💡 [llm-manager/doubao] 已发送,等待生成完成后再复制(最长 180s)...") + + # 发送前记录:用于确认点的是新增复制按钮,且剪贴板确实变化了 + left_copy = self.page.locator('[data-testid="message_action_copy"]') + right_copy = self.page.locator('[data-testid="container_inner_copy_btn"]') + left_prev = await left_copy.count() + right_prev = await right_copy.count() + clipboard_before = await self._safe_read_clipboard() + + # 稳定检测:避免生成中“喇叭短暂出现”误判(需要连续多次可见) + speaker = self.page.locator( + f'svg path[d*="{_DOUBAO_SPEAKER_PATH_SNIPPET}"]' + ).last + stable_needed = 3 + stable = 0 + interval_sec = 0.5 + deadline = time.monotonic() + 180.0 + while time.monotonic() < deadline: + try: + if await speaker.is_visible(): + stable += 1 + else: + stable = 0 + except Exception: + stable = 0 + if stable >= stable_needed: + break + await asyncio.sleep(interval_sec) + else: + return "ERROR:DOUBAO_WAIT_COMPLETE_TIMEOUT 180 秒内未检测到“喇叭稳定回显”。" + + # 生成完成后:依据当前页面状态决定点右侧还是左侧复制 + write_mode = await self._is_write_mode() + if write_mode: + if await self._wait_count_gt(right_copy, right_prev, timeout_sec=15.0): + copy_btn = right_copy.last + elif await self._wait_count_gt(left_copy, left_prev, timeout_sec=15.0): + copy_btn = left_copy.last + else: + return "ERROR:DOUBAO_COPY_BUTTON_NOT_READY 右侧/左侧复制按钮均未出现新增。" + else: + if await self._wait_count_gt(left_copy, left_prev, timeout_sec=15.0): + copy_btn = left_copy.last + elif await self._wait_count_gt(right_copy, right_prev, timeout_sec=5.0): + copy_btn = right_copy.last + else: + return "ERROR:DOUBAO_COPY_BUTTON_NOT_READY 左侧复制按钮未出现新增。" + + try: + await copy_btn.scroll_into_view_if_needed() + await copy_btn.click(timeout=15_000, force=True) + except Exception as e: + return f"ERROR:DOUBAO_COPY_CLICK_FAILED {e}" + + out = await self._wait_clipboard_changed(clipboard_before, timeout_sec=20.0) + if out is None: + return "ERROR:DOUBAO_CLIPBOARD_NOT_UPDATED 剪贴板未在规定时间内更新。" + if self._is_invalid_clipboard(out, clipboard_before=clipboard_before): + return f"ERROR:DOUBAO_CLIPBOARD_INVALID {out[:200]}" + + await asyncio.sleep(5) + return out + + async def _is_write_mode(self) -> bool: + marker = self.page.locator('div.bottom-entry-GSWErB:has-text("改用对话直接回答")').first + try: + return await marker.is_visible() + except Exception: + return False + + async def _wait_count_gt(self, locator, prev_count: int, timeout_sec: float) -> bool: + deadline = time.monotonic() + timeout_sec + while time.monotonic() < deadline: + try: + if await locator.count() > prev_count: + return True + except Exception: + pass + await asyncio.sleep(0.3) + return False + + async def _wait_clipboard_changed( + self, clipboard_before: str | None, timeout_sec: float + ) -> str | None: + deadline = time.monotonic() + timeout_sec + last = clipboard_before + while time.monotonic() < deadline: + cur = await self._safe_read_clipboard() + if cur and cur != last and not self._is_invalid_clipboard( + cur, clipboard_before=clipboard_before + ): + return cur.strip() + last = cur + await asyncio.sleep(0.4) + return None + + async def _try_copy_right(self, right_copy) -> str | None: + # 右侧复制按钮 + try: + btn = right_copy.first + await btn.wait_for(state="visible", timeout=2000) + await btn.scroll_into_view_if_needed() + await btn.click(timeout=5000, force=True) + except Exception: + return None + + await asyncio.sleep(0.4) + out = await self._safe_read_clipboard() + if self._is_invalid_clipboard(out): + return None + if out == (await self._safe_read_clipboard()): + pass + return (out or "").strip() + + async def _try_copy_left(self, left_copy, prev_count: int) -> str | None: + n = await left_copy.count() + if n <= prev_count: + return None + + # 从新增区间末尾往前找第一个可点击的复制按钮 + for idx in range(n - 1, prev_count - 1, -1): + btn = left_copy.nth(idx) + try: + if not await btn.is_visible(): + continue + await btn.scroll_into_view_if_needed() + await btn.click(timeout=5000, force=True) + except Exception: + continue + + await asyncio.sleep(0.4) + out = await self._safe_read_clipboard() + if self._is_invalid_clipboard(out): + continue + return (out or "").strip() + return None + + def _is_invalid_clipboard( + self, text: str | None, clipboard_before: str | None = None + ) -> bool: + if not text: + return True + t = str(text).strip() + if not t: + return True + if clipboard_before is not None and t == str(clipboard_before).strip(): + return True + if t.startswith("ERROR:"): + return True + # 防止误点拿到 UI 文案/占位文本 + banned_substrings = ( + "改用对话直接回答", + "新对话", + "正在生成", + "复制", + "下载", + "快捷", + "发消息", + ) + if any(s in t for s in banned_substrings): + return True + if "data-testid" in t or "<button" in t: + return True + # 经验阈值:正常正文不会太短 + if len(t) < 30: + return True + return False + + async def _safe_read_clipboard(self) -> str | None: + try: + return await self.page.evaluate("navigator.clipboard.readText()") + except Exception: + return None diff --git a/llm-manager/scripts/engines/kimi.py b/llm-manager/scripts/engines/kimi.py new file mode 100644 index 0000000..0d30df7 --- /dev/null +++ b/llm-manager/scripts/engines/kimi.py @@ -0,0 +1,48 @@ +import asyncio +from .base import BaseEngine + + +class KimiEngine(BaseEngine): + async def generate(self, prompt: str) -> str: + await self.page.goto("https://kimi.moonshot.cn/") + await self.page.wait_for_load_state("networkidle") + + # 登录检测:等待输入框出现,超时则未登录 + try: + editor = self.page.locator(".chat-input-editor").first + await editor.wait_for(state="visible", timeout=10000) + except Exception: + return "ERROR:REQUIRE_LOGIN" + + # 输入提示词(insert_text 避免换行符被误触发) + await editor.click() + await self.page.keyboard.insert_text(prompt) + await asyncio.sleep(0.5) + + # 点击发送按钮 + await self.page.locator(".send-button-container").first.click() + + print("💡 [llm-manager/kimi] 已发送提示词,等待 Kimi 生成响应...") + + # 等待 2 秒让发送按钮进入"生成中"状态 + await asyncio.sleep(2) + + # 等待 Send SVG 图标重新出现(表示生成完毕) + try: + send_icon = self.page.locator('.send-button-container svg[name="Send"]') + await send_icon.wait_for(state="visible", timeout=150000) + except Exception: + return "ERROR:Kimi 生成超时(150秒未见完成标志),可能网络卡住或内容被拦截。" + + # 稳妥起见多等 2 秒,让复制按钮完全渲染 + await asyncio.sleep(2) + + # 点击最后一条回复的复制按钮,通过剪贴板取完整文本 + try: + copy_btn = self.page.locator('svg[name="Copy"]').last + await copy_btn.click() + await asyncio.sleep(0.5) + result = await self.page.evaluate("navigator.clipboard.readText()") + return result.strip() + except Exception as e: + return f"ERROR:抓取 Kimi 内容时异常:{e}" diff --git a/llm-manager/scripts/engines/qianwen.py b/llm-manager/scripts/engines/qianwen.py new file mode 100644 index 0000000..124778a --- /dev/null +++ b/llm-manager/scripts/engines/qianwen.py @@ -0,0 +1,91 @@ +""" +通义千问网页版驱动引擎(tongyi.aliyun.com/qianwen)。 + +选择器说明(如页面改版需更新): +- 输入框:#search-input 或 textarea[class*="input"](textarea) +- 发送按钮:button[class*="send"] 或 [aria-label="发送"] +- 停止生成:button[class*="stop"] 或 [aria-label="停止"] +- 复制按钮:最后一条回复下的 [class*="copy"] 或 [aria-label="复制"] +""" +import asyncio +from .base import BaseEngine + + +class QianwenEngine(BaseEngine): + async def generate(self, prompt: str) -> str: + await self.page.goto("https://tongyi.aliyun.com/qianwen/") + await self.page.wait_for_load_state("networkidle") + + # 登录检测 + input_selectors = [ + "#search-input", + "textarea[class*='input']", + "div[class*='input'][contenteditable='true']", + ] + editor = None + for sel in input_selectors: + try: + loc = self.page.locator(sel).first + await loc.wait_for(state="visible", timeout=4000) + editor = loc + break + except Exception: + continue + if editor is None: + return "ERROR:REQUIRE_LOGIN" + + # 输入提示词 + await editor.click() + await self.page.keyboard.insert_text(prompt) + await asyncio.sleep(0.5) + + # 发送 + sent = False + for sel in ( + 'button[class*="send"]', + '[aria-label="发送"]', + 'button[type="submit"]', + ): + try: + btn = self.page.locator(sel).first + if await btn.is_visible(timeout=1000): + await btn.click() + sent = True + break + except Exception: + continue + if not sent: + await self.page.keyboard.press("Enter") + + print("💡 [llm-manager/qianwen] 已发送提示词,等待通义千问生成响应...") + await asyncio.sleep(2) + + # 等待生成完毕 + stop_selectors = ['button[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]'] + deadline = asyncio.get_event_loop().time() + 150 + while asyncio.get_event_loop().time() < deadline: + stop_visible = False + for sel in stop_selectors: + try: + if await self.page.locator(sel).first.is_visible(timeout=300): + stop_visible = True + break + except Exception: + pass + if not stop_visible: + break + await asyncio.sleep(2) + + await asyncio.sleep(2) + + # 取结果 + try: + copy_btn = self.page.locator( + '[aria-label="复制"], [class*="copy-btn"], button:has(svg[class*="copy"])' + ).last + await copy_btn.click() + await asyncio.sleep(0.5) + result = await self.page.evaluate("navigator.clipboard.readText()") + return result.strip() + except Exception as e: + return f"ERROR:抓取通义千问内容时异常:{e}" diff --git a/llm-manager/scripts/engines/yiyan.py b/llm-manager/scripts/engines/yiyan.py new file mode 100644 index 0000000..350b134 --- /dev/null +++ b/llm-manager/scripts/engines/yiyan.py @@ -0,0 +1,91 @@ +""" +文心一言网页版驱动引擎(yiyan.baidu.com)。 + +选择器说明(如页面改版需更新): +- 输入框:[class*="editor"] 或 div[contenteditable="true"](富文本编辑器) +- 发送按钮:[class*="send-btn"] 或 [aria-label="发送"] +- 停止生成:[class*="stop"] 相关按钮 +- 复制按钮:最后一条回复下的复制按钮 +""" +import asyncio +from .base import BaseEngine + + +class YiyanEngine(BaseEngine): + async def generate(self, prompt: str) -> str: + await self.page.goto("https://yiyan.baidu.com") + await self.page.wait_for_load_state("networkidle") + + # 登录检测 + input_selectors = [ + "div[class*='editor'][contenteditable='true']", + "textarea[class*='input']", + "[contenteditable='true']", + ] + editor = None + for sel in input_selectors: + try: + loc = self.page.locator(sel).first + await loc.wait_for(state="visible", timeout=4000) + editor = loc + break + except Exception: + continue + if editor is None: + return "ERROR:REQUIRE_LOGIN" + + # 输入提示词 + await editor.click() + await self.page.keyboard.insert_text(prompt) + await asyncio.sleep(0.5) + + # 发送 + sent = False + for sel in ( + '[class*="send-btn"]', + '[aria-label="发送"]', + 'button[class*="send"]', + ): + try: + btn = self.page.locator(sel).first + if await btn.is_visible(timeout=1000): + await btn.click() + sent = True + break + except Exception: + continue + if not sent: + await self.page.keyboard.press("Enter") + + print("💡 [llm-manager/yiyan] 已发送提示词,等待文心一言生成响应...") + await asyncio.sleep(2) + + # 等待生成完毕 + stop_selectors = ['[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]'] + deadline = asyncio.get_event_loop().time() + 150 + while asyncio.get_event_loop().time() < deadline: + stop_visible = False + for sel in stop_selectors: + try: + if await self.page.locator(sel).first.is_visible(timeout=300): + stop_visible = True + break + except Exception: + pass + if not stop_visible: + break + await asyncio.sleep(2) + + await asyncio.sleep(2) + + # 取结果 + try: + copy_btn = self.page.locator( + '[aria-label="复制"], [class*="copy"], button:has(svg[class*="copy"])' + ).last + await copy_btn.click() + await asyncio.sleep(0.5) + result = await self.page.evaluate("navigator.clipboard.readText()") + return result.strip() + except Exception as e: + return f"ERROR:抓取文心一言内容时异常:{e}" diff --git a/llm-manager/scripts/engines/yuanbao.py b/llm-manager/scripts/engines/yuanbao.py new file mode 100644 index 0000000..d038676 --- /dev/null +++ b/llm-manager/scripts/engines/yuanbao.py @@ -0,0 +1,94 @@ +""" +腾讯元宝网页版驱动引擎(yuanbao.tencent.com)。 +元宝暂无公开 API,仅支持网页模式。 + +选择器说明(如页面改版需更新): +- 输入框:[class*="input-area"] textarea 或 [contenteditable="true"] +- 发送按钮:[class*="send-btn"] 或 [aria-label="发送"] +- 停止生成:[class*="stop"] 相关按钮 +- 复制按钮:最后一条回复下的复制按钮 +""" +import asyncio +from .base import BaseEngine + + +class YuanbaoEngine(BaseEngine): + async def generate(self, prompt: str) -> str: + await self.page.goto("https://yuanbao.tencent.com/chat") + await self.page.wait_for_load_state("networkidle") + + # 登录检测 + input_selectors = [ + "textarea[class*='input']", + "div[class*='input-area'] textarea", + "[contenteditable='true']", + "textarea", + ] + editor = None + for sel in input_selectors: + try: + loc = self.page.locator(sel).first + await loc.wait_for(state="visible", timeout=4000) + editor = loc + break + except Exception: + continue + if editor is None: + return "ERROR:REQUIRE_LOGIN" + + # 输入提示词 + await editor.click() + await self.page.keyboard.insert_text(prompt) + await asyncio.sleep(0.5) + + # 发送 + sent = False + for sel in ( + '[class*="send-btn"]', + '[aria-label="发送"]', + 'button[class*="send"]', + 'button[type="submit"]', + ): + try: + btn = self.page.locator(sel).first + if await btn.is_visible(timeout=1000): + await btn.click() + sent = True + break + except Exception: + continue + if not sent: + await self.page.keyboard.press("Enter") + + print("💡 [llm-manager/yuanbao] 已发送提示词,等待腾讯元宝生成响应...") + await asyncio.sleep(2) + + # 等待生成完毕 + stop_selectors = ['[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]'] + deadline = asyncio.get_event_loop().time() + 150 + while asyncio.get_event_loop().time() < deadline: + stop_visible = False + for sel in stop_selectors: + try: + if await self.page.locator(sel).first.is_visible(timeout=300): + stop_visible = True + break + except Exception: + pass + if not stop_visible: + break + await asyncio.sleep(2) + + await asyncio.sleep(2) + + # 取结果 + try: + copy_btn = self.page.locator( + '[aria-label="复制"], [class*="copy"], button:has(svg[class*="copy"])' + ).last + await copy_btn.click() + await asyncio.sleep(0.5) + result = await self.page.evaluate("navigator.clipboard.readText()") + return result.strip() + except Exception as e: + return f"ERROR:抓取腾讯元宝内容时异常:{e}" diff --git a/llm-manager/scripts/main.py b/llm-manager/scripts/main.py new file mode 100644 index 0000000..1d45c19 --- /dev/null +++ b/llm-manager/scripts/main.py @@ -0,0 +1,575 @@ +""" +llm-manager 主入口 CLI。 + +子命令: + health 快速离线健康检查 + version 输出版本 JSON + add <platform> [api_key] 添加 API Key(不传 api_key 则走网页账号关联) + key list [platform] 列出 Key(打码) + key del <key_id> 删除 Key + generate <platform_or_account_id> 生成内容(优先网页模式,备用 API Key 模式) + "<prompt>" + +generate 调度规则: + 1. 若 target 为纯数字 → 视为 account-manager 账号 ID → 强制网页模式 + 2. 若 target 为平台名/别名: + a. 先查 account-manager 有无该平台已登录账号 → 网页模式(免费) + b. 再查 llm_keys 有无可用 Key → API Key 模式(付费) + c. 两者均无 → 报错并给出操作指引 +""" +import sys +import json +import os +import asyncio +import subprocess + +# Windows GBK 编码兼容修复 +if sys.platform == "win32": + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OPENCLAW_DIR = os.path.dirname(BASE_DIR) + +# 确保 scripts 目录在 sys.path,使 providers/db 可直接 import +_scripts_dir = os.path.dirname(os.path.abspath(__file__)) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) + +from providers import ( + LLM_PROVIDERS, + resolve_provider_key, + provider_list_cn, + find_logged_in_account, + resolve_chromium_channel, +) +from db import ( + add_key, + upsert_web_account, + list_keys, + list_web_accounts, + get_key_by_id, + delete_key, + find_active_key, + mark_key_used, + _mask_key, +) +from engines.api_engine import ApiEngine +from engines.kimi import KimiEngine +from engines.doubao import DoubaoEngine +from engines.deepseek import DeepSeekEngine +from engines.qianwen import QianwenEngine +from engines.yiyan import YiyanEngine +from engines.yuanbao import YuanbaoEngine + +# 平台 slug → 网页引擎类(全部 6 个平台) +WEB_ENGINES = { + "doubao": DoubaoEngine, + "deepseek": DeepSeekEngine, + "qianwen": QianwenEngine, + "kimi": KimiEngine, + "yiyan": YiyanEngine, + "yuanbao": YuanbaoEngine, +} + +SKILL_VERSION = "1.0.3" + + +def _engine_result_is_error(text: str) -> bool: + """网页/API 引擎约定:失败时返回以 ERROR: 开头的字符串,不得当作正文包进 LLM 标记块。""" + return (text or "").lstrip().startswith("ERROR:") + + +def _unix_to_iso(ts): + if ts is None: + return "" + try: + import datetime as _dt + + return _dt.datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") + except Exception: + return str(ts) + + +# --------------------------------------------------------------------------- +# account-manager 跨 Skill 调用 +# --------------------------------------------------------------------------- + +def _get_account_from_manager(account_id) -> dict | None: + """通过 account-manager CLI 按 ID 取账号 JSON。""" + script = os.path.join(OPENCLAW_DIR, "account-manager", "scripts", "main.py") + try: + result = subprocess.run( + [sys.executable, script, "get", str(account_id)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + raw = result.stdout.strip() + if raw.startswith("ERROR"): + return None + return json.loads(raw) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# 网页模式 +# --------------------------------------------------------------------------- + +async def _run_web_generate(account: dict, prompt: str) -> bool: + from playwright.async_api import async_playwright + + platform = account.get("platform", "") + profile_dir = account.get("profile_dir", "").strip() + + engine_cls = WEB_ENGINES.get(platform) + if not engine_cls: + print(f"ERROR:ENGINE_NOT_FOUND 平台 {platform} 暂无网页引擎实现。") + return False + + if not profile_dir or not os.path.isdir(profile_dir): + print(f"ERROR:PROFILE_DIR_MISSING 账号 {account.get('id')} 的浏览器数据目录不存在:{profile_dir}") + print("请先通过 account-manager 执行登录:python account-manager/scripts/main.py login <id>") + return False + + channel = resolve_chromium_channel() + if not channel: + print("ERROR:浏览器未找到。请安装 Google Chrome 或 Microsoft Edge 后重试。") + return False + + print(f"[llm-manager] 使用网页模式 | 平台: {LLM_PROVIDERS.get(platform, {}).get('label', platform)} | 账号: {account.get('name', account.get('id'))}") + + async with async_playwright() as p: + browser = await p.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=False, + channel=channel, + no_viewport=True, + permissions=["clipboard-read", "clipboard-write"], + args=["--start-maximized"], + ) + try: + page = browser.pages[0] if browser.pages else await browser.new_page() + engine = engine_cls(page) + try: + result = await engine.generate(prompt) + except Exception as e: + print(f"ERROR:WEB_GENERATE_FAILED 网页模式生成失败:{e}") + return False + + result = (result or "").strip() + if _engine_result_is_error(result): + print(result) + return False + + print("===LLM_START===") + print(result) + print("===LLM_END===") + return True + finally: + await asyncio.sleep(3) + await browser.close() + + +# --------------------------------------------------------------------------- +# API Key 模式 +# --------------------------------------------------------------------------- + +def _run_api_generate(provider_key: str, key_record: dict, prompt: str) -> bool: + provider = LLM_PROVIDERS[provider_key] + api_base = provider.get("api_base") + model = (key_record.get("default_model") or "").strip() or (provider.get("api_model") or "").strip() + + if not api_base: + print(f"ERROR:NO_API_BASE {provider['label']} 暂无 API 地址(不支持 API 模式)。") + return False + if not model: + print( + f"ERROR:API_MODEL_MISSING {provider['label']} 需要指定模型名称。\n" + f"提示:{provider.get('api_note', '')}\n" + f"请删除该 Key 并重新添加时带上 --model 参数:python main.py add {provider_key} \"Key\" --model \"模型名\"" + ) + return False + + print(f"[llm-manager] 使用 API Key 模式 | 平台: {provider['label']} | 模型: {model}") + + try: + engine = ApiEngine(api_base=api_base, api_key=key_record["api_key"], model=model) + result = engine.generate(prompt) + except Exception as e: + print(f"ERROR:API_GENERATE_FAILED API 模式调用失败:{e}") + return False + result = (result or "").strip() + if _engine_result_is_error(result): + print(result) + return False + mark_key_used(key_record["id"]) + + print("===LLM_START===") + print(result) + print("===LLM_END===") + return True + + +# --------------------------------------------------------------------------- +# generate 命令 +# --------------------------------------------------------------------------- + +def cmd_generate(target: str, prompt: str) -> bool: + """ + target 可以是: + - 纯数字 → account-manager 账号 ID(强制网页模式) + - 平台名/别名 → 自动选择模式(网页优先,API Key 备用) + 成功返回 True,任一步失败返回 False(调用方应设进程退出码非 0)。 + """ + target = (target or "").strip() + prompt = (prompt or "").strip() + if not prompt: + print("ERROR:PROMPT_EMPTY 提示词不能为空。") + return False + + # ---- 情况 1:显式 account_id(纯数字)→ 强制网页模式 ---- + if target.isdigit(): + account = _get_account_from_manager(target) + if not account: + print(f"ERROR:ACCOUNT_NOT_FOUND 未在「模型管理」对应的账号列表中找到 id={target}。") + print("请先在模型管理中添加该平台账号,或检查 id 是否抄错。") + return False + if account.get("login_status") != 1: + print( + f"ERROR:REQUIRE_LOGIN 账号「{account.get('name', target)}」尚未完成网页登录。\n" + "请先在「模型管理」里确认已添加该账号,再执行下面命令完成登录:\n" + f" python account-manager/scripts/main.py login {target}\n" + "若使用网页模式:请先在对应平台官网注册好账号,再回到本工具添加并登录。" + ) + return False + return asyncio.run(_run_web_generate(account, prompt)) + + # ---- 情况 2:平台名 → 自动选模式 ---- + provider_key = resolve_provider_key(target) + if not provider_key: + print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{target}」。") + print("支持的平台:" + provider_list_cn()) + return False + + provider = LLM_PROVIDERS[provider_key] + label = provider["label"] + web_url = (provider.get("web_url") or "").strip() + + # 优先级 1:查 account-manager 已登录账号(网页模式,免费) + account = find_logged_in_account(provider_key) + if account: + return asyncio.run(_run_web_generate(account, prompt)) + + # 优先级 2:查本地 API Key(付费) + key_record = find_active_key(provider_key) + if key_record: + return _run_api_generate(provider_key, key_record, prompt) + + # 两种凭据均无 → 明确说明「模型管理 + 网页模式需官网账号」 + print(f"ERROR:NO_CREDENTIAL 当前没有可用的「{label}」调用凭据(既没有已登录的网页账号,也没有可用的 API Key)。") + print() + print("【推荐 · 网页模式(免费)】按顺序做:") + print(f" ① 打开「模型管理」(与 OpenClaw 里 account-manager 账号管理是同一套数据),先把「{label}」账号添加进去。") + print(" 命令行示例(在 OpenClaw 根目录执行):") + print(f" python account-manager/scripts/main.py add \"{label}\" \"你的手机号或登录名\"") + print(" python account-manager/scripts/main.py login <上一步返回的账号 id>") + print(" ② 若走网页模式:请先在对应平台官方网站注册并创建好账号(否则浏览器里登录会失败)。") + if web_url: + print(f" 「{label}」网页入口:{web_url}") + print() + if provider.get("has_api"): + print("【备选 · API Key(付费)】若已在厂商控制台开通 API,可本地登记 Key 后走接口调用:") + print(f" python llm-manager/scripts/main.py add {provider_key} \"你的API Key\"") + if provider.get("api_note"): + print(f" 说明:{provider['api_note']}") + else: + print(f"说明:「{label}」暂无公开 API,只能使用上面的网页模式。") + return False + + +# --------------------------------------------------------------------------- +# key 子命令 +# --------------------------------------------------------------------------- + +def cmd_key_add(provider_input: str, api_key: str = "", model: str = None, label: str = ""): + provider_key = resolve_provider_key(provider_input) + if not provider_key: + print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{provider_input}」。") + print("支持:" + provider_list_cn()) + return + provider = LLM_PROVIDERS[provider_key] + api_key = (api_key or "").strip() + + # 不传 api_key:默认走网页版本,检查 account-manager 是否已有该平台已登录账号 + if not api_key: + account = find_logged_in_account(provider_key) + if account: + web_row_id = upsert_web_account( + provider=provider_key, + account_id=int(account.get("id")), + account_name=(account.get("name") or account.get("account_name") or ""), + login_status=int(account.get("login_status") or 0), + ) + print( + f"OK:WEB_ACCOUNT_READY 已关联网页模式账号 | 平台: {provider['label']} " + f"| account_id: {account.get('id')} | 账号: {account.get('name') or account.get('account_name') or ''}" + ) + print(f"已写入 llm-manager 记录:WEB_ID {web_row_id}") + print(f"后续可直接调用:python main.py generate {provider_key} \"<提示词>\"") + return + print(f"ERROR:WEB_ACCOUNT_NOT_FOUND 未找到已登录的「{provider['label']}」账号,暂时无法走网页模式。") + print("请先在 account-manager 添加并登录该平台账号:") + print(f" python account-manager/scripts/main.py add \"{provider['label']}\" \"你的登录名\"") + print(" python account-manager/scripts/main.py login <账号id>") + if provider.get("has_api"): + print("或直接提供 API Key:") + print(f" python main.py add {provider_key} \"<API_Key>\"") + return + + if not provider.get("has_api"): + print(f"ERROR:{provider['label']} 暂无公开 API,不支持 API Key 模式。") + print("请改用网页模式并先在 account-manager 完成登录。") + return + + new_id = add_key(provider=provider_key, api_key=api_key, model=model, label=label) + print(f"✅ 已保存 API Key:ID {new_id} | {provider['label']} | 模型: {model or provider.get('api_model') or '(未指定)'} | {_mask_key(api_key)}") + if not model and not provider.get("api_model"): + print(f"⚠️ 注意:该平台需要指定模型名称,否则调用时会报错。") + print(f" {provider.get('api_note', '')}") + print(f" 可删除后重新添加:python main.py key del {new_id}") + + +def cmd_key_list(provider_input: str = None, limit: int = 10): + provider_key = None + if provider_input: + provider_key = resolve_provider_key(provider_input) + if not provider_key: + print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{provider_input}」。") + return + + keys = list_keys(provider_key, limit=limit) + web_accounts = list_web_accounts(provider_key, limit=limit) + if not keys and not web_accounts: + print("暂无记录(API Key / 网页账号关联 都为空)。") + print("添加 API Key:python main.py add <platform> \"API Key\" [--model 模型名]") + print("添加网页账号关联:python main.py add <platform>") + return + + sep_line = "_" * 39 + rows = [] + for k in keys: + rows.append({ + "type": "api_key", + "created_at": int(k.get("created_at") or 0), + "payload": k, + }) + for w in web_accounts: + rows.append({ + "type": "web_account", + "created_at": int(w.get("created_at") or 0), + "payload": w, + }) + rows.sort(key=lambda x: (x["created_at"], int(x["payload"].get("id") or 0)), reverse=True) + + for idx, row in enumerate(rows): + if row["type"] == "api_key": + k = row["payload"] + print("record_type:api_key") + print(f"id:{k['id']}") + print(f"platform:{k['provider']}") + print(f"platform_cn:{LLM_PROVIDERS.get(k['provider'], {}).get('label', k['provider'])}") + print(f"label:{k.get('label') or ''}") + print(f"api_key:{_mask_key(k.get('api_key') or '')}") + print(f"default_model:{k.get('default_model') or ''}") + print(f"is_active:{int(k.get('is_active') or 0)}") + print(f"last_used_at:{_unix_to_iso(k.get('last_used_at'))}") + print(f"created_at:{_unix_to_iso(k.get('created_at'))}") + else: + w = row["payload"] + print("record_type:web_account") + print(f"id:{w['id']}") + print(f"platform:{w['provider']}") + print(f"platform_cn:{LLM_PROVIDERS.get(w['provider'], {}).get('label', w['provider'])}") + print(f"account_id:{w.get('account_id')}") + print(f"account_name:{w.get('account_name') or ''}") + print(f"login_status:{int(w.get('login_status') or 0)}") + print(f"created_at:{_unix_to_iso(w.get('created_at'))}") + print(f"updated_at:{_unix_to_iso(w.get('updated_at'))}") + if idx != len(rows) - 1: + print(sep_line) + print() + + +def cmd_key_del(key_id_str: str): + if not key_id_str.isdigit(): + print(f"ERROR:INVALID_ID key_id 须为正整数,收到「{key_id_str}」。") + return + key_id = int(key_id_str) + key = get_key_by_id(key_id) + if not key: + print(f"ERROR:KEY_NOT_FOUND 未找到 ID={key_id} 的 API Key。") + return + delete_key(key_id) + label_str = f"({key['label']})" if key.get("label") else "" + print(f"✅ 已删除:ID {key_id} | {LLM_PROVIDERS.get(key['provider'], {}).get('label', key['provider'])}{label_str} | {_mask_key(key['api_key'])}") + + +# --------------------------------------------------------------------------- +# health / version +# --------------------------------------------------------------------------- + +def cmd_health(): + ok = sys.version_info >= (3, 10) + sys.exit(0 if ok else 1) + + +def cmd_version(): + print(json.dumps({"version": SKILL_VERSION, "skill": "llm-manager"}, ensure_ascii=False)) + + +# --------------------------------------------------------------------------- +# CLI 解析 +# --------------------------------------------------------------------------- + +def _print_usage(): + print("用法:") + print(" python main.py health") + print(" python main.py version") + print(" python main.py generate <平台名或account_id> \"<提示词>\"") + print(" python main.py add <平台> [API_Key] [--model 模型名] [--label 备注]") + print(" python main.py list [平台] [--limit 条数]") + print(" python main.py del <key_id>") + print() + print("支持的平台:" + provider_list_cn()) + print() + print("generate 说明:") + print(" · 传入 account_id(数字)→ 指定账号网页模式(需先用 account-manager 登录)") + print(" · 传入平台名 → 自动选择:优先网页模式(免费),无可用账号时才用 API Key(付费)") + print() + print("兼容说明:key add/list/del 旧写法仍可用。") + + +def main(argv=None) -> int: + args = argv if argv is not None else sys.argv[1:] + if not args: + _print_usage() + return 1 + if args[0] in ("-h", "--help"): + _print_usage() + return 0 + + def _parse_list_args(rest_args): + platform_filter = None + limit = 10 + i = 0 + while i < len(rest_args): + if rest_args[i] == "--limit" and i + 1 < len(rest_args): + try: + limit = int(rest_args[i + 1]) + except ValueError: + print(f"ERROR:CLI_KEY_LIST_BAD_LIMIT limit 必须是整数,收到「{rest_args[i + 1]}」。") + return None, None, 1 + i += 2 + else: + if platform_filter is None: + platform_filter = rest_args[i] + i += 1 + return platform_filter, limit, 0 + + def _parse_add_args(rest_args): + if len(rest_args) < 1: + print("ERROR:CLI_ADD_MISSING_ARGS") + print("用法:python main.py add <平台> [API_Key] [--model 模型] [--label 备注]") + return None, None, None, None, 1 + platform_arg = rest_args[0] + api_key_arg = "" + model_arg = None + label_arg = "" + i = 1 + if i < len(rest_args) and not rest_args[i].startswith("--"): + api_key_arg = rest_args[i] + i += 1 + while i < len(rest_args): + if rest_args[i] == "--model" and i + 1 < len(rest_args): + model_arg = rest_args[i + 1] + i += 2 + elif rest_args[i] == "--label" and i + 1 < len(rest_args): + label_arg = rest_args[i + 1] + i += 2 + else: + i += 1 + return platform_arg, api_key_arg, model_arg, label_arg, 0 + + cmd = args[0] + + if cmd == "health": + cmd_health() + + elif cmd == "version": + cmd_version() + + elif cmd == "generate": + if len(args) < 3: + print("ERROR:CLI_GENERATE_MISSING_ARGS") + print("用法:python main.py generate <平台名或account_id> \"<提示词>\"") + return 1 + if not cmd_generate(args[1], args[2]): + return 1 + + elif cmd == "list": + platform_filter, limit, err = _parse_list_args(args[1:]) + if err: + return err + cmd_key_list(platform_filter, limit=limit) + + elif cmd == "add": + platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[1:]) + if err: + return err + cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg) + + elif cmd == "del": + if len(args) < 2: + print("ERROR:CLI_DEL_MISSING_ARGS 用法:python main.py del <key_id>") + return 1 + cmd_key_del(args[1]) + + elif cmd == "key": + if len(args) < 2: + print("ERROR:CLI_KEY_MISSING_SUBCOMMAND 请指定子命令:add / list / del") + return 1 + sub = args[1] + + if sub == "add": + platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[2:]) + if err: + return err + cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg) + + elif sub == "list": + platform_filter, limit, err = _parse_list_args(args[2:]) + if err: + return err + cmd_key_list(platform_filter, limit=limit) + + elif sub == "del": + if len(args) < 3: + print("ERROR:CLI_KEY_DEL_MISSING_ARGS 用法:python main.py key del <key_id>") + return 1 + cmd_key_del(args[2]) + + else: + print(f"ERROR:CLI_UNKNOWN_KEY_SUB 未知 key 子命令「{sub}」,支持:add / list / del") + return 1 + + else: + print(f"ERROR:CLI_UNKNOWN_COMMAND 未知命令「{cmd}」。") + _print_usage() + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/llm-manager/scripts/providers.py b/llm-manager/scripts/providers.py new file mode 100644 index 0000000..1efb4f2 --- /dev/null +++ b/llm-manager/scripts/providers.py @@ -0,0 +1,199 @@ +""" +平台配置中心:7 个 LLM 平台的静态配置、别名解析、以及通过 account-manager 暴露的 CLI 查询已登录网页账号。 +""" +import json +import os +import subprocess +import sys + +# --------------------------------------------------------------------------- +# 平台静态配置 +# --------------------------------------------------------------------------- +LLM_PROVIDERS = { + "doubao": { + "label": "豆包", + "aliases": ["豆包"], + "web_url": "https://www.doubao.com/chat/", + "api_base": "https://ark.volces.com/api/v3", + "api_model": None, # 豆包需要用户在火山引擎控制台建推理接入点,model=ep-xxx + "has_api": True, + "api_note": "需先在火山引擎控制台创建推理接入点,--model 传 endpoint_id(格式 ep-xxx)", + }, + "deepseek": { + "label": "DeepSeek", + "aliases": ["深度求索"], + "web_url": "https://chat.deepseek.com", + "api_base": "https://api.deepseek.com/v1", + "api_model": "deepseek-chat", + "has_api": True, + "api_note": "模型可选:deepseek-chat / deepseek-reasoner", + }, + "qianwen": { + "label": "通义千问", + "aliases": ["通义", "千问", "qwen", "tongyi"], + "web_url": "https://tongyi.aliyun.com/qianwen/", + "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_model": "qwen-plus", + "has_api": True, + "api_note": "模型可选:qwen-turbo / qwen-plus / qwen-max", + }, + "kimi": { + "label": "Kimi", + "aliases": ["月之暗面", "moonshot"], + "web_url": "https://kimi.moonshot.cn", + "api_base": "https://api.moonshot.cn/v1", + "api_model": "moonshot-v1-8k", + "has_api": True, + "api_note": "模型可选:moonshot-v1-8k / moonshot-v1-32k / moonshot-v1-128k", + }, + "yiyan": { + "label": "文心一言", + "aliases": ["文心", "一言", "ernie", "wenxin"], + "web_url": "https://yiyan.baidu.com", + "api_base": "https://qianfan.baidubce.com/v2", + "api_model": "ernie-4.0-8k", + "has_api": True, + "api_note": "模型可选:ernie-4.0-8k / ernie-3.5-8k", + }, + "yuanbao": { + "label": "腾讯元宝", + "aliases": ["元宝"], + "web_url": "https://yuanbao.tencent.com/chat", + "api_base": None, + "api_model": None, + "has_api": False, + "api_note": "暂无公开 API,仅支持网页模式", + }, + "minimax": { + "label": "MiniMax", + "aliases": ["minimax", "MiniMax", "海螺", "海螺AI"], + "web_url": "https://chat.minimax.io/", + "api_base": "https://api.minimax.chat/v1", + "api_model": "MiniMax-Text-01", + "has_api": True, + "api_note": "模型可按 MiniMax 控制台可用模型调整,建议通过 --model 显式指定。", + }, +} + +# 构建别名查找表(含中文、英文键、aliases) +_ALIAS_TO_KEY: dict = {} +for _k, _spec in LLM_PROVIDERS.items(): + _ALIAS_TO_KEY[_k] = _k + _ALIAS_TO_KEY[_k.lower()] = _k + _ALIAS_TO_KEY[_spec["label"]] = _k + for _a in (_spec.get("aliases") or []): + _ALIAS_TO_KEY[_a] = _k + _ALIAS_TO_KEY[_a.lower()] = _k + + +def resolve_provider_key(name: str): + """将用户输入的平台名称/别名解析为内部 slug;无法识别返回 None。""" + if not name: + return None + s = str(name).strip() + return _ALIAS_TO_KEY.get(s) or _ALIAS_TO_KEY.get(s.lower()) + + +def provider_list_cn() -> str: + return "、".join(s["label"] for s in LLM_PROVIDERS.values()) + + +# --------------------------------------------------------------------------- +# 路径帮助(与 account-manager/scripts/main.py 完全一致:仅 JIANGCHANG_*) +# --------------------------------------------------------------------------- + +_OPENCLAW_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def get_data_root() -> str: + env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() + if env: + return env + if sys.platform == "win32": + return r"D:\jiangchang-data" + return os.path.join(os.path.expanduser("~"), ".jiangchang-data") + + +def get_user_id() -> str: + uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() + return uid or "_anon" + + +# --------------------------------------------------------------------------- +# 跨技能:仅通过 account-manager 提供的 CLI 读取账号(不直接打开其数据库文件) +# --------------------------------------------------------------------------- + +def _account_manager_script_path() -> str: + return os.path.join(_OPENCLAW_DIR, "account-manager", "scripts", "main.py") + + +def find_logged_in_account(provider_key: str) -> dict | None: + """ + 调用 account-manager:pick-logged-in <platform_key>,取该平台已登录(login_status=1)的优先账号。 + 成功返回与 account.py get 一致的 dict;失败或不可用时返回 None。 + """ + script = _account_manager_script_path() + if not os.path.isfile(script): + return None + try: + proc = subprocess.run( + [sys.executable, script, "pick-logged-in", provider_key], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError: + return None + raw = (proc.stdout or "").strip() + if not raw or raw.startswith("ERROR:"): + return None + try: + data = json.loads(raw.splitlines()[0]) + except (json.JSONDecodeError, IndexError): + return None + if not isinstance(data, dict) or data.get("id") is None: + return None + plat = data.get("platform") or provider_key + if not (data.get("url") or "").strip(): + data["url"] = LLM_PROVIDERS.get(provider_key, {}).get("web_url", "") + data["platform"] = plat + return data + + +# --------------------------------------------------------------------------- +# Chrome/Edge 检测(与 account-manager 逻辑保持一致) +# --------------------------------------------------------------------------- + +def _win_find_exe(candidates): + for p in candidates: + if p and os.path.isfile(p): + return p + return None + + +def resolve_chromium_channel() -> str | None: + """返回 'chrome' | 'msedge' | None。""" + if sys.platform != "win32": + return "chrome" + pf = os.environ.get("ProgramFiles", r"C:\Program Files") + pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + local = os.environ.get("LocalAppData", "") + + chrome = _win_find_exe([ + os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "", + ]) + if chrome: + return "chrome" + + edge = _win_find_exe([ + os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"), + os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"), + os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "", + ]) + if edge: + return "msedge" + + return None diff --git a/logistics-tracker/.github/workflows/release_skill.yaml b/logistics-tracker/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..21617a6 --- /dev/null +++ b/logistics-tracker/.github/workflows/release_skill.yaml @@ -0,0 +1,11 @@ +name: 技能自动化发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/logistics-tracker/SKILL.md b/logistics-tracker/SKILL.md new file mode 100644 index 0000000..abc5329 --- /dev/null +++ b/logistics-tracker/SKILL.md @@ -0,0 +1,53 @@ +--- +name: 物流轨迹查询 +description: 物流轨迹查询。当用户发送物流单号、询问包裹状态、问"货到哪了"、"帮我查单号"等时使用。调用17Track API查询最新轨迹并用中文回复。支持全球200+承运商。 +version: 1.0.0 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: logistics-tracker + emoji: "📦" + category: "通用" + requires: + env: + - TRACK17_API_KEY + bins: + - python3 +allowed-tools: + - bash +--- + +# 物流轨迹查询 + +## 使用时机 + +当用户发送以下内容时触发本Skill: +- 包含物流单号(字母+数字组合,如 `RR123456789CN`、`UPS1234567890`) +- 询问"我的货到哪了"、"帮我查单号"、"物流查询"、"查一下快递"等 + +## 执行步骤 + +1. 从用户消息中提取物流单号 +2. 执行查询脚本: +```bash +python3 {baseDir}/scripts/main.py <单号> +``` + +3. 将脚本返回结果直接回复给用户 +4. 如果用户没有提供单号,回复:"请发送您要查询的物流单号,我来帮您查询最新状态。" + +## 输出格式 + +脚本会返回格式化好的中文结果,直接发送给用户即可,无需二次处理。 + +## 错误处理 + +- 查询失败:告知用户"该单号暂时查询不到,可能未入网,请稍后再试" +- 网络超时:告知用户"网络连接超时,请稍后重试" +- 无效单号:告知用户"单号格式不正确,请检查后重新发送" + +## 注意事项 + +- 每次只处理一个单号 +- API Key 通过环境变量 `TRACK17_API_KEY` 读取,不要硬编码在脚本里 +- 免费套餐每月100次查询,正式使用需升级 \ No newline at end of file diff --git a/logistics-tracker/release.ps1 b/logistics-tracker/release.ps1 new file mode 100644 index 0000000..0e7c8c5 --- /dev/null +++ b/logistics-tracker/release.ps1 @@ -0,0 +1,220 @@ +<# +.SYNOPSIS + One-command release script for skill repos. + +.DESCRIPTION + - Optional auto-commit + - Push current branch + - Auto-increment semantic tag (vX.Y.Z) + - Create & push tag + - Fail fast on unsafe states + +.EXAMPLES + # Safe mode (recommended): requires clean working tree + .\release.ps1 + + # Auto commit tracked/untracked changes then release + .\release.ps1 -AutoCommit -CommitMessage "chore: update skill config" + + # Dry run (show what would happen) + .\release.ps1 -DryRun + + # Custom tag prefix + .\release.ps1 -Prefix "v" -Message "正式发布" + +.NOTES + Requires: git, PowerShell 5+ +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) +if (Test-Path $sharedScript) { + & $sharedScript @PSBoundParameters + exit $LASTEXITCODE +} + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string]$Args) + Write-Host ">> git $Args" -ForegroundColor DarkGray + & cmd /c "git $Args" + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } +} + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string]$Args) + $output = & cmd /c "git $Args" 2>$null + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } + return @($output) +} + +function Test-Repo { + & git rev-parse --is-inside-work-tree *> $null + return ($LASTEXITCODE -eq 0) +} + +function Get-CurrentBranch { + $b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim() + return $b +} + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput "status --porcelain") + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput "tag --list" + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + & git status --short + throw "Abort: dirty working tree." + } + + # 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启 + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git "add -A" + Invoke-Git "commit -m `"$commitMsg`"" +} + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + Invoke-Git "fetch --tags --prune origin" + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null) + $hasUpstream = ($LASTEXITCODE -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git "push" + } else { + Invoke-Git "push -u origin $branch" + } + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + + $existing = @(Get-GitOutput "tag --list `"$nextTag`"") + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-Git "tag -a $nextTag -m `"$Message`"" + Invoke-Git "push origin $nextTag" + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/logistics-tracker/scripts/main.py b/logistics-tracker/scripts/main.py new file mode 100644 index 0000000..d9f0036 --- /dev/null +++ b/logistics-tracker/scripts/main.py @@ -0,0 +1,142 @@ +import requests +import sys +import os +import subprocess + + +def get_api_key(key_name): + """从api-key-vault读取Key""" + vault_path = os.path.join( + os.path.dirname(__file__), + "..", "..", # 从scripts/退到logistics-tracker/再退到OpenClaw/ + "api-key-vault", "scripts", "vault.py" + ) + vault_path = os.path.normpath(vault_path) + + result = subprocess.run( + ["python", vault_path, "get", key_name], + capture_output=True, text=True + ) + key = result.stdout.strip() + + if not key or key == "ERROR:KEY_NOT_FOUND": + return None + return key + + +def check_entitlement(skill_slug): + auth_base = (os.getenv("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/") + if not auth_base: + return True, "" + + user_id = (os.getenv("JIANGCHANG_USER_ID") or "").strip() + if not user_id: + return False, "鉴权失败:缺少用户身份(JIANGCHANG_USER_ID)" + + auth_api_key = (os.getenv("JIANGCHANG_AUTH_API_KEY") or "").strip() + timeout = int((os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip()) + headers = {"Content-Type": "application/json"} + if auth_api_key: + headers["Authorization"] = f"Bearer {auth_api_key}" + + payload = { + "user_id": user_id, + "skill_slug": skill_slug, + "trace_id": (os.getenv("JIANGCHANG_TRACE_ID") or "").strip(), + "context": {"entry": "main.py"}, + } + try: + res = requests.post( + f"{auth_base}/api/entitlements/check", + json=payload, + headers=headers, + timeout=timeout, + ) + except requests.RequestException as exc: + return False, f"鉴权请求失败:{exc}" + + if res.status_code != 200: + return False, f"鉴权服务异常:HTTP {res.status_code}" + try: + body = res.json() + except ValueError: + return False, "鉴权服务异常:返回非 JSON" + + code = body.get("code") + data = body.get("data") or {} + if code != 200: + return False, str(body.get("msg") or "鉴权失败") + if not data.get("allow", False): + return False, str(data.get("reason") or "未购买或已过期") + return True, "" + + +def query_tracking(tracking_number): + api_key = get_api_key("17track") + if not api_key: + return "错误:未找到17track的API Key,请先运行:\npython api-key-vault/scripts/vault.py set 17track 你的Key" + + url = "https://api.17track.net/track/v2.2/gettrackinfo" + headers = { + "Content-Type": "application/json", + "17token": api_key + } + body = { + "data": [{"number": tracking_number}] + } + + try: + response = requests.post(url, json=body, headers=headers, timeout=10) + result = response.json() + + accepted = result.get("data", {}).get("accepted", []) + if not accepted: + return f"抱歉,单号 {tracking_number} 查询不到信息,可能还未入网,请稍后再试。" + + track_info = accepted[0].get("track", {}) + providers = track_info.get("tracking", {}).get("providers", []) + + if not providers or not providers[0].get("events"): + return f"📦 单号:{tracking_number}\n该单号已入网,暂无轨迹更新,请稍后再查。" + + events = providers[0]["events"] + latest = events[0] + + recent_lines = [] + for e in events[:3]: + time = e.get("time_iso", "") + location = e.get("location", "") or "未知" + desc = e.get("description", "") + recent_lines.append(f" · {time} {location} {desc}") + + recent_text = "\n".join(recent_lines) + + return ( + f"📦 单号:{tracking_number}\n" + f"📍 最新状态:{latest.get('description', '未知')}\n" + f"🕐 更新时间:{latest.get('time_iso', '未知')}\n" + f"🗺 最新位置:{latest.get('location', '未知')}\n\n" + f"最近轨迹:\n{recent_text}" + ) + + except requests.exceptions.Timeout: + return "查询超时,请稍后重试。" + except Exception as e: + return f"查询失败:{str(e)}" + + +def main(argv=None) -> int: + args = argv if argv is not None else sys.argv[1:] + if len(args) < 1: + print("用法:python main.py <单号>") + return 1 + ok, reason = check_entitlement("logistics-tracker") + if not ok: + print(f"❌ {reason}") + return 1 + print(query_tracking(args[0])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/skill-template/README.md b/skill-template/README.md index 3d79a32..c47e1c1 100644 --- a/skill-template/README.md +++ b/skill-template/README.md @@ -16,12 +16,14 @@ 4. **扩展子命令**:在 `scripts/skill_main.py` 的 `dispatch` 中增加分支;业务逻辑放在同目录其它模块或子包中,保持入口轻薄。 5. **编写/调整 `SKILL.md`**:只改「何时触发、如何调用、参数含义」,不要写实现细节;实现细节放在 `docs/` 或代码注释里。 6. **发布**:若使用 GitHub Actions,编辑 `.github/workflows/release_skill.yaml`,把 `uses:` 指向**你们组织**的复用工作流;若不用 CI,可删除该目录。 +7. **一键打标签推送(与匠厂 monorepo 对齐)**:在技能仓库根目录执行 `.\release.ps1`(需与 `jiangchang-platform-kit` 位于同一父目录,以便调用 `..\jiangchang-platform-kit\tools\release.ps1`)。支持 `-DryRun`、`-AutoCommit`、`-CommitMessage` 等参数,与 `account-manager` / `sohu-publisher` 一致。 ## 目录一览 | 路径 | 作用 | |------|------| | `SKILL.md` | 技能清单(YAML 头 + Markdown 正文),供宿主与协作者阅读 | +| `release.ps1` | 转调平台套件的发布脚本(提交/推送/语义化 tag);依赖并列的 `jiangchang-platform-kit` | | `scripts/skill_main.py` | 推荐唯一 CLI 入口;含 `health` / `version` 示例 | | `docs/RUNTIME.md` | 环境与目录契约(多宿主通用) | | `docs/SKILL_TYPES.md` | 常见技能形态与自检清单 | diff --git a/skill-template/docs/LOGGING.md b/skill-template/docs/LOGGING.md new file mode 100644 index 0000000..f5a1212 --- /dev/null +++ b/skill-template/docs/LOGGING.md @@ -0,0 +1,59 @@ +# OpenClaw 技能日志约定(各技能统一) + +本文档为**规范**:新技能与 `account-manager` 等现有技能应遵循同一套路径、格式与环境变量,便于排查与运维。 + +## 目录与文件 + +- 日志目录与技能数据目录同级: + - `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/logs/` +- 主日志文件(示例): + - `{...}/account-manager/logs/account-manager.log` +- `skill_slug` 与 `SKILL.md` 中 `metadata.openclaw.slug` 一致。 + +## 轮转与编码 + +- 使用 Python `logging.handlers.TimedRotatingFileHandler`,`when=midnight`,保留约 30 个历史文件(可按技能调整 `backupCount`)。 +- 文件编码 **UTF-8**。 +- 首次写日志前创建 `logs/` 目录(`exist_ok=True`)。 + +## 日志格式(行文本) + +推荐单行格式(与 `account-manager` 一致): + +```text +%(asctime)s | %(levelname)-8s | %(name)s | %(message)s +``` + +`datefmt` 建议:`%Y-%m-%dT%H:%M:%S`(本地时间)。 + +## Logger 命名 + +- 使用分层命名,便于按前缀过滤,例如:`openclaw.skill.account_manager`。 +- 子进程若需写同一文件,可使用子 logger:`openclaw.skill.account_manager.login_child`,仍追加到**同一日志文件**(`logging.FileHandler(..., encoding='utf-8')` 追加模式)。 + +## 环境变量(全局) + +| 变量 | 说明 | +|------|------| +| `JIANGCHANG_LOG_LEVEL` | 默认 `INFO`;调试轮询等可设 `DEBUG`。 | +| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 时,将 `WARNING` 及以上同时输出到 `stderr`(不替代文件)。 | +| `JIANGCHANG_{SKILL}_LOG_FILE` | 可选,覆盖该技能主日志**绝对路径**(需自行保证目录可写)。`account-manager` 使用 `JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE`。 | + +## 级别与内容建议 + +- **INFO**:子命令入口、成功结束(含关键业务 id,避免仅打「成功」)。 +- **WARNING**:可恢复问题、校验失败、登录未检测到。 +- **ERROR**:依赖缺失、不可继续的异常。 +- **DEBUG**:高频轮询状态(URL 列表、规则命中说明);默认关闭。 +- 日志中可能含 **手机号、路径等敏感信息**,注意文件权限与留存策略;必要时仅记录后四位或 hash(按合规要求)。 + +## 与标准输出的关系 + +- 面向用户的 `print`(成功提示、`ERROR:` 前缀)可保留;**诊断细节以日志为准**。 +- 登录失败等场景可在终端提示日志文件路径,便于用户打开 `*.log`。 + +## 参考实现 + +- `account-manager/scripts/main.py`:`get_skill_logs_dir`、`get_skill_logger`、`get_skill_log_file_path`、login 子进程内 `FileHandler` 追加同一文件。 + +新技能可复制上述函数结构,替换 `SKILL_SLUG` 与 `LOG_LOGGER_NAME` 即可。 diff --git a/skill-template/release.ps1 b/skill-template/release.ps1 new file mode 100644 index 0000000..639081a --- /dev/null +++ b/skill-template/release.ps1 @@ -0,0 +1,23 @@ +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) + +if (-not (Test-Path $sharedScript)) { + throw "Shared release script not found: $sharedScript" +} + +& $sharedScript @PSBoundParameters +exit $LASTEXITCODE diff --git a/sohu-publisher/.github/workflows/release_skill.yaml b/sohu-publisher/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..229e053 --- /dev/null +++ b/sohu-publisher/.github/workflows/release_skill.yaml @@ -0,0 +1,11 @@ +name: 技能自动化发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 \ No newline at end of file diff --git a/sohu-publisher/SKILL.md b/sohu-publisher/SKILL.md new file mode 100644 index 0000000..047e874 --- /dev/null +++ b/sohu-publisher/SKILL.md @@ -0,0 +1,77 @@ +--- +name: 搜狐号自动发布 +description: 搜狐号自动发布大总管。系统会自动帮你查找文章ID和账号ID,严禁向用户询问代码层的参数! +version: 1.1.0 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: sohu-publisher + emoji: "📰" + category: "通用" + dependencies: + required: + - content-manager + - account-manager + auto_install: true +allowed-tools: + - bash +--- + +# 搜狐号全自动发布指引 + +## 🚨 AI 必备纪律 (严禁直接询问用户参数 ❗) + +你是一个面向小白用户的顶级全自动运营总监。用户**不懂代码**,他们不知道什么是 `article_id=3` ,也不懂什么是 `account_id=sohu_account1`。 +用户只会用自己的视角跟你下达命令(比如:“帮我把最新的一篇发到搜狐号”、“用 13800000000 那个账号发一下文章”)。 + +当你收到这类含有发布意图的指令时,**绝对不能打断用户,要求他们提供 account_id 和 article_id**。你需要按照以下【静默 3 步走】机制,替用户去把需要的值找出来! + +### 🎯 步骤 1:自动感知识别【文章数字 ID】 +如果你不知道确切的文章ID数字是多少(比如用户只说了:发下最新的,或者发刚写好的): +1. 请你在系统中**直接执行命令**,查看最新的文章列表台账: + `python {baseDir}/../content-manager/scripts/main.py list` +2. 阅读返回的表格。通过列表最顶部的记录,或者是标题匹配您的上下文,**你自己**把那篇目标文章的最左侧**数字 ID**挖出来。千万别去问用户。 + +### 🎯 步骤 2:自动感知识别【账号 ID】 +用户通常用**手机号**区分账号。如果你不知道该用哪个发: +1. 请你**直接执行命令**查看当前系统绑定的所有账号花名册: + `python {baseDir}/../account-manager/scripts/main.py list` +2. 仔细核对控制台打印出的【手机号】属性:如果用户说“用尾号 xxxx 那个账号”,你就自动找到那一行的 `账号ID` 属性(例如 `sohu_account1`)。 +3. **特例**:如果你发现其实系统里只绑定了 1 个搜狐账号,你连查都不用查,直接默认提取 `sohu_account1` 充当参数。绝对不要拿这种事麻烦用户! + +### 🎯 步骤 3:直接一键开火发布 +当你自己在后台摸清楚了 `account_id`(字符串)和 `article_id`(数字) 后,请热情地对用户说:“好的老大,这就开始帮您操作发布...”,然后立刻替用户在终端执以下命令发起物理发布引擎: + +```bash +python {baseDir}/scripts/main.py <account_id> <article_id> +``` + +**示例**: +`python {baseDir}/scripts/main.py sohu_account1 3` + +## 常用 CLI(给小白也能看懂) + +```bash +# 发布一篇(推荐显式写 publish) +python {baseDir}/scripts/main.py publish <account_id> <article_id> + +# 兼容旧写法(等价于 publish) +python {baseDir}/scripts/main.py <account_id> <article_id> + +# 查看发布记录(默认最新 10 条,按创建时间倒序) +python {baseDir}/scripts/main.py logs +python {baseDir}/scripts/main.py logs --limit 20 +python {baseDir}/scripts/main.py logs --status failed +python {baseDir}/scripts/main.py logs --account-id sohu_account1 + +# 查看某一条发布记录(JSON) +python {baseDir}/scripts/main.py log-get <log_id> + +# 健康/版本 +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py version +``` + +--- +> 💡 若你执行发布脚本后,控制台向你抛出了 `ERROR:REQUIRE_LOGIN` 警告和一系列提示。这说明你成功启动了代理人流程,但搜狐账号已经掉线了!这时脚本实际上已经替您调起了浏览器的登录弹窗。 +> 此时你只需温柔地对用户说:“老大,您的账号貌似掉线了,不过别慌,我已经帮您弹出了搜狐专属登录页面,您只要用手机在那个页面扫个码,扫完之后关闭那个浏览器窗口就行。等您弄好了告诉我,我再帮您从头发布一次!” \ No newline at end of file diff --git a/sohu-publisher/release.ps1 b/sohu-publisher/release.ps1 new file mode 100644 index 0000000..0e7c8c5 --- /dev/null +++ b/sohu-publisher/release.ps1 @@ -0,0 +1,220 @@ +<# +.SYNOPSIS + One-command release script for skill repos. + +.DESCRIPTION + - Optional auto-commit + - Push current branch + - Auto-increment semantic tag (vX.Y.Z) + - Create & push tag + - Fail fast on unsafe states + +.EXAMPLES + # Safe mode (recommended): requires clean working tree + .\release.ps1 + + # Auto commit tracked/untracked changes then release + .\release.ps1 -AutoCommit -CommitMessage "chore: update skill config" + + # Dry run (show what would happen) + .\release.ps1 -DryRun + + # Custom tag prefix + .\release.ps1 -Prefix "v" -Message "正式发布" + +.NOTES + Requires: git, PowerShell 5+ +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) +if (Test-Path $sharedScript) { + & $sharedScript @PSBoundParameters + exit $LASTEXITCODE +} + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string]$Args) + Write-Host ">> git $Args" -ForegroundColor DarkGray + & cmd /c "git $Args" + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } +} + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string]$Args) + $output = & cmd /c "git $Args" 2>$null + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $Args" + } + return @($output) +} + +function Test-Repo { + & git rev-parse --is-inside-work-tree *> $null + return ($LASTEXITCODE -eq 0) +} + +function Get-CurrentBranch { + $b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim() + return $b +} + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput "status --porcelain") + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput "tag --list" + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + & git status --short + throw "Abort: dirty working tree." + } + + # 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启 + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git "add -A" + Invoke-Git "commit -m `"$commitMsg`"" +} + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + Invoke-Git "fetch --tags --prune origin" + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null) + $hasUpstream = ($LASTEXITCODE -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git "push" + } else { + Invoke-Git "push -u origin $branch" + } + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + + $existing = @(Get-GitOutput "tag --list `"$nextTag`"") + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-Git "tag -a $nextTag -m `"$Message`"" + Invoke-Git "push origin $nextTag" + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/sohu-publisher/scripts/main.py b/sohu-publisher/scripts/main.py new file mode 100644 index 0000000..18f5ce6 --- /dev/null +++ b/sohu-publisher/scripts/main.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +sohu-publisher:搜狐号自动发布。 + +子命令: + publish <account_id> <article_id> 发布文章 + logs [--limit N] [--status s] [--account-id a] 查看发布记录 + log-get <log_id> 查看单条发布记录(JSON) + health | version +""" + +from __future__ import annotations + +import argparse +import asyncio +import io +import json +import os +import sqlite3 +import subprocess +import sys +import time +from datetime import datetime +from typing import Any, Dict, List, Optional + +import requests +from playwright.async_api import async_playwright + +if sys.platform == "win32": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +SKILL_SLUG = "sohu-publisher" +SKILL_VERSION = "1.2.0" +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OPENCLAW_DIR = os.path.dirname(BASE_DIR) + + +def _now_unix() -> int: + return int(time.time()) + + +def _unix_to_iso(ts: Optional[int]) -> Optional[str]: + if ts is None: + return None + try: + return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") + except (ValueError, OSError, OverflowError): + return None + + +def get_data_root() -> str: + env = (os.getenv("CLAW_DATA_ROOT") or os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() + if env: + return env + if sys.platform == "win32": + return r"D:\claw-data" + return os.path.join(os.path.expanduser("~"), ".claw-data") + + +def get_user_id() -> str: + return (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon" + + +def get_skills_root() -> str: + env = (os.getenv("CLAW_SKILLS_ROOT") or os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip() + if env: + return env + return OPENCLAW_DIR + + +def get_skill_data_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) + os.makedirs(path, exist_ok=True) + return path + + +def get_db_path() -> str: + return os.path.join(get_skill_data_dir(), "sohu-publisher.db") + + +def get_conn() -> sqlite3.Connection: + return sqlite3.connect(get_db_path()) + + +def init_db() -> None: + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS publish_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL, + article_id INTEGER NOT NULL, + article_title TEXT, + status TEXT NOT NULL, + error_msg TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + cur.execute("PRAGMA table_info(publish_logs)") + cols = {r[1] for r in cur.fetchall()} + if "article_title" not in cols: + cur.execute("ALTER TABLE publish_logs ADD COLUMN article_title TEXT") + if "updated_at" not in cols: + cur.execute("ALTER TABLE publish_logs ADD COLUMN updated_at INTEGER") + if "created_at" not in cols: + cur.execute("ALTER TABLE publish_logs ADD COLUMN created_at INTEGER") + conn.commit() + finally: + conn.close() + + +def save_publish_log(account_id: str, article_id: int, article_title: str, status: str, error_msg: Optional[str] = None) -> int: + init_db() + now = _now_unix() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO publish_logs (account_id, article_id, article_title, status, error_msg, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (account_id, int(article_id), article_title or "", status, error_msg, now, now), + ) + new_id = int(cur.lastrowid) + conn.commit() + finally: + conn.close() + return new_id + + +def check_entitlement(skill_slug: str) -> tuple[bool, str]: + auth_base = (os.getenv("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/") + if not auth_base: + return True, "" + user_id = (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip() + if not user_id: + return False, "鉴权失败:缺少用户身份(CLAW_USER_ID / JIANGCHANG_USER_ID)" + + auth_api_key = (os.getenv("JIANGCHANG_AUTH_API_KEY") or "").strip() + timeout = int((os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip()) + headers = {"Content-Type": "application/json"} + if auth_api_key: + headers["Authorization"] = f"Bearer {auth_api_key}" + payload = { + "user_id": user_id, + "skill_slug": skill_slug, + "trace_id": (os.getenv("JIANGCHANG_TRACE_ID") or "").strip(), + "context": {"entry": "main.py"}, + } + try: + res = requests.post(f"{auth_base}/api/entitlements/check", json=payload, headers=headers, timeout=timeout) + except requests.RequestException as exc: + return False, f"鉴权请求失败:{exc}" + if res.status_code != 200: + return False, f"鉴权服务异常:HTTP {res.status_code}" + try: + body = res.json() + except ValueError: + return False, "鉴权服务异常:返回非 JSON" + code = body.get("code") + data = body.get("data") or {} + if code != 200: + return False, str(body.get("msg") or "鉴权失败") + if not data.get("allow", False): + return False, str(data.get("reason") or "未购买或已过期") + return True, "" + + +def _call_json_script(script_path: str, args: List[str]) -> Optional[Dict[str, Any]]: + proc = subprocess.run([sys.executable, script_path, *args], capture_output=True, text=True, encoding="utf-8", errors="replace") + raw = (proc.stdout or "").strip() + if not raw or raw.startswith("ERROR"): + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def _normalize_sohu_title(raw_title: str, article: Dict[str, Any]) -> str: + """ + 搜狐标题约束:5-72 字。 + - >72:截断 + - <5:用正文首行/前缀补足 + """ + title = (raw_title or "").strip() + if len(title) > 72: + title = title[:72].rstrip() + + if len(title) >= 5: + return title + + body = str(article.get("content") or article.get("content_html") or "").strip() + first_line = "" + for line in body.splitlines(): + t = line.strip() + if t: + first_line = t + break + seed = first_line or "搜狐发布稿件" + seed = seed.replace("\r", " ").replace("\n", " ").strip() + + # 先把 seed 拼上,仍不足时再补固定后缀,最终保证 >=5 + merged = (title + seed).strip() + if len(merged) < 5: + merged = (merged + "发布稿件标题").strip() + if len(merged) > 72: + merged = merged[:72].rstrip() + return merged + + +def get_account(account_id: str) -> Optional[Dict[str, Any]]: + script = os.path.join(get_skills_root(), "account-manager", "scripts", "main.py") + return _call_json_script(script, ["get", str(account_id)]) + + +def get_article(article_id: str) -> Optional[Dict[str, Any]]: + script = os.path.join(get_skills_root(), "content-manager", "scripts", "main.py") + return _call_json_script(script, ["get", str(article_id)]) + + +async def publish(account: Dict[str, Any], article: Dict[str, Any], account_id: str) -> str: + profile_dir = account["profile_dir"] + original_title = str(article.get("title") or "") + title = _normalize_sohu_title(original_title, article) + if title != original_title: + print(f"ℹ️ 标题已自动修正(搜狐要求 5-72 字):\n 原标题:{original_title}\n 新标题:{title}") + content_html = article.get("content_html", article.get("content", "")) + + async with async_playwright() as p: + browser = await p.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=False, + channel="chrome", + no_viewport=True, + permissions=["clipboard-read", "clipboard-write"], + args=["--start-maximized"], + ) + page = browser.pages[0] if browser.pages else await browser.new_page() + await page.goto("https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?contentStatus=1") + await page.wait_for_load_state("networkidle") + + try: + title_input = page.locator(".publish-title input").first + await title_input.wait_for(state="visible", timeout=10000) + except Exception: + await browser.close() + return "ERROR:REQUIRE_LOGIN" + + print("💡 页面加载且已确认登录,开始自动填入文字...") + await title_input.click() + await title_input.fill("") + await page.keyboard.insert_text(title) + await asyncio.sleep(1) + + editor = page.locator("#editor .ql-editor").first + await editor.click() + await page.evaluate( + """(html_str) => { + const blobHtml = new Blob([html_str], { type: 'text/html' }); + const blobText = new Blob([html_str], { type: 'text/plain' }); + const item = new window.ClipboardItem({ + 'text/html': blobHtml, + 'text/plain': blobText + }); + return navigator.clipboard.write([item]); + }""", + content_html, + ) + + modifier = "Meta" if sys.platform == "darwin" else "Control" + await page.keyboard.press(f"{modifier}+v") + await asyncio.sleep(3) + await page.locator("li.publish-report-btn").first.click() + print("⌛ 正在提交发布,进入高压视觉核验阶段...") + + publish_success = False + error_text = "动作执行阻断:由于特殊元素拦截、频率限制或底层报错,未能成功发出。" + try: + async with page.expect_navigation(url=lambda u: "addarticle" not in u, timeout=8000): + pass + publish_success = True + except Exception: + try: + limit_text = page.locator("text=/.*已达上限.*/").first + if await limit_text.is_visible(timeout=1500): + error_text = await limit_text.inner_text() + else: + error_msg = await page.evaluate( + """() => { + const els = Array.from(document.querySelectorAll('div, span, p')); + for (let el of els) { + const style = window.getComputedStyle(el); + if ((style.position === 'fixed' || style.position === 'absolute') + && parseInt(style.zIndex || 0) > 80 + && el.innerText.trim().length > 3 + && el.innerText.trim().length < 80) { + return el.innerText.trim(); + } + } + return null; + }""" + ) + if error_msg: + error_text = f"抓取到报错原文: {error_msg}" + except Exception as e: + error_text = f"抓取报错文案期间遭遇环境隔离: {e}" + + await asyncio.sleep(5) + await browser.close() + if publish_success: + return "SUCCESS" + return f"FAIL:{error_text}" + + +def cmd_publish(account_id: str, article_id: str) -> int: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + print(f"❌ {reason}") + return 1 + if not str(article_id).isdigit(): + print("❌ article_id 必须是数字。请先执行 content-manager 的 list 查看 id。") + return 1 + + account = get_account(account_id) + if not account: + print(f"❌ 查无此配置账号:{account_id}") + return 1 + platform = str(account.get("platform") or "").strip().lower() + if platform != "sohu": + platform_cn_map = { + "doubao": "豆包", + "deepseek": "DeepSeek", + "qianwen": "通义千问", + "kimi": "Kimi", + "yiyan": "文心一言", + "yuanbao": "腾讯元宝", + "toutiao": "头条号", + "zhihu": "知乎", + "wechat": "微信公众号", + "sohu": "搜狐号", + } + got_cn = platform_cn_map.get(platform, platform or "未知平台") + print("❌ 账号平台不匹配:当前账号不是「搜狐号」。") + print(f"当前 account_id={account_id} 对应平台:{got_cn}(platform={platform or 'unknown'})") + print("请换一个搜狐账号 id 后重试。") + print("可先执行:python account-manager/scripts/main.py list sohu") + return 1 + login_status = int(account.get("login_status") or 0) + if login_status != 1: + print("❌ 该搜狐账号当前未登录,暂不能发布。") + print("请先手工登录,再执行发布:") + print(f" python account-manager/scripts/main.py login {account_id}") + print(f"登录完成后再执行:python sohu-publisher/scripts/main.py publish {account_id} {article_id}") + return 1 + article = get_article(article_id) + if not article: + print(f"❌ 查无此文章编号(库中无 ID: {article_id})") + return 1 + + result = asyncio.run(publish(account, article, account_id)) + content_script = os.path.join(get_skills_root(), "content-manager", "scripts", "main.py") + title = article.get("title", "") + + if result == "ERROR:REQUIRE_LOGIN": + save_publish_log(account_id, int(article_id), title, "require_login", "账号未登录或登录已失效") + print(f"⚠️ 搜狐号 ({account_id}) 登录状态已失效,发布流程已中止。") + print("请先手工完成登录,再重新发布:") + print(f" python account-manager/scripts/main.py login {account_id}") + print(f" python sohu-publisher/scripts/main.py publish {account_id} {article_id}") + return 1 + + if result == "SUCCESS": + log_id = save_publish_log(account_id, int(article_id), title, "published", None) + subprocess.run([sys.executable, content_script, "feedback", article_id, "published", account_id]) + print(f"🎉 发布成功:{title}") + print(f"✅ 发布日志已记录,log_id={log_id}") + return 0 + + if result.startswith("FAIL:"): + error_msg = result[len("FAIL:") :] + log_id = save_publish_log(account_id, int(article_id), title, "failed", error_msg) + subprocess.run([sys.executable, content_script, "feedback", article_id, "failed", account_id, error_msg]) + print(f"❌ 发布失败:{error_msg}") + print(f"✅ 失败日志已记录,log_id={log_id}") + return 1 + + save_publish_log(account_id, int(article_id), title, "failed", f"未知结果:{result}") + return 1 + + +def cmd_logs(limit: int = 10, status: Optional[str] = None, account_id: Optional[str] = None) -> int: + init_db() + if limit <= 0: + limit = 10 + conn = get_conn() + try: + cur = conn.cursor() + sql = ( + "SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at " + "FROM publish_logs WHERE 1=1 " + ) + params: List[Any] = [] + if status: + sql += "AND status = ? " + params.append(status) + if account_id: + sql += "AND account_id = ? " + params.append(account_id) + sql += "ORDER BY created_at DESC, id DESC LIMIT ?" + params.append(int(limit)) + cur.execute(sql, tuple(params)) + rows = cur.fetchall() + finally: + conn.close() + + if not rows: + print("暂无发布记录") + return 0 + + sep_line = "_" * 39 + for idx, r in enumerate(rows): + rid, aid, arid, title, st, err, cat, uat = r + print(f"id:{rid}") + print(f"account_id:{aid or ''}") + print(f"article_id:{arid}") + print(f"article_title:{title or ''}") + print(f"status:{st or ''}") + print(f"error_msg:{err or ''}") + print(f"created_at:{_unix_to_iso(cat) or str(cat or '')}") + print(f"updated_at:{_unix_to_iso(uat) or str(uat or '')}") + if idx != len(rows) - 1: + print(sep_line) + print() + return 0 + + +def cmd_log_get(log_id: str) -> int: + if not str(log_id).isdigit(): + print("❌ log_id 必须是数字") + return 1 + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at FROM publish_logs WHERE id = ?", + (int(log_id),), + ) + row = cur.fetchone() + finally: + conn.close() + if not row: + print("❌ 没有这条发布记录") + return 1 + rid, aid, arid, title, st, err, cat, uat = row + print( + json.dumps( + { + "id": int(rid), + "account_id": aid, + "article_id": int(arid), + "article_title": title, + "status": st, + "error_msg": err, + "created_at": _unix_to_iso(cat), + "updated_at": _unix_to_iso(uat), + }, + ensure_ascii=False, + ) + ) + return 0 + + +class ZhArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + print(f"参数错误:{message}\n请执行:python main.py -h 查看帮助", file=sys.stderr) + self.exit(2) + + +def _print_full_usage() -> None: + print("搜狐号发布(main.py)可用命令:") + print(" python main.py publish <account_id> <article_id> # 发布一篇") + print(" python main.py logs [--limit N] [--status s] [--account-id a] # 查看发布记录") + print(" python main.py log-get <log_id> # 查看单条日志(JSON)") + print(" python main.py health") + print(" python main.py version") + print() + print("常见示例:") + print(" python main.py publish sohu_account1 12") + print(" python main.py logs") + print(" python main.py logs --status failed --limit 20") + print(" python main.py log-get 7") + print() + print("说明:也兼容旧写法:python main.py <account_id> <article_id>") + + +def build_parser() -> ZhArgumentParser: + p = ZhArgumentParser( + prog="main.py", + description="搜狐号发布:发布文章、查看发布记录、查询单条日志。", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "示例:\n" + " python main.py publish sohu_account1 12\n" + " python main.py logs\n" + " python main.py logs --status failed --limit 20\n" + " python main.py log-get 7\n" + " python main.py health\n" + " python main.py version" + ), + ) + sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) + + sp = sub.add_parser("publish", help="发布一篇文章到搜狐号") + sp.add_argument("account_id", help="账号 id(来自 account-manager list)") + sp.add_argument("article_id", help="文章 id(来自 content-manager list)") + sp.set_defaults(handler=lambda a: cmd_publish(a.account_id, a.article_id)) + + sp = sub.add_parser("logs", help="查看发布记录(默认最近 10 条)") + sp.add_argument("--limit", type=int, default=10, help="最多显示条数(默认 10)") + sp.add_argument("--status", default=None, help="按状态筛选:published/failed/require_login") + sp.add_argument("--account-id", default=None, help="按账号 id 筛选") + sp.set_defaults(handler=lambda a: cmd_logs(limit=a.limit, status=a.status, account_id=a.account_id)) + + sp = sub.add_parser("log-get", help="按 log_id 查看单条发布记录(JSON)") + sp.add_argument("log_id", help="日志 id(整数)") + sp.set_defaults(handler=lambda a: cmd_log_get(a.log_id)) + + sp = sub.add_parser("health", help="健康检查") + sp.set_defaults(handler=lambda _a: 0 if sys.version_info >= (3, 10) else 1) + + sp = sub.add_parser("version", help="版本信息(JSON)") + sp.set_defaults( + handler=lambda _a: ( + print(json.dumps({"version": SKILL_VERSION, "skill": SKILL_SLUG}, ensure_ascii=False)) or 0 + ) + ) + return p + + +def main(argv: Optional[List[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + if not argv: + _print_full_usage() + return 1 + # 兼容旧用法:python main.py <account_id> <article_id> + if len(argv) == 2 and argv[0] not in {"publish", "logs", "log-get", "health", "version", "-h", "--help"}: + return cmd_publish(argv[0], argv[1]) + parser = build_parser() + args = parser.parse_args(argv) + return int(args.handler(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/toutiao-publisher/.github/workflows/release_skill.yaml b/toutiao-publisher/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..c6a3e09 --- /dev/null +++ b/toutiao-publisher/.github/workflows/release_skill.yaml @@ -0,0 +1,13 @@ +# 打 tag(如 v1.0.2)后触发:加密打包、调用平台 API 同步元数据(入库)、上传制品、清理旧版本。 +# 逻辑在 admin/jiangchang-platform-kit 的 reusable-release-skill.yaml 中。 +name: 头条技能发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/toutiao-publisher/.gitignore b/toutiao-publisher/.gitignore new file mode 100644 index 0000000..db5cc0b --- /dev/null +++ b/toutiao-publisher/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.pyo +.Python +.venv/ +venv/ +.env +.env.* diff --git a/toutiao-publisher/README.md b/toutiao-publisher/README.md new file mode 100644 index 0000000..a318c22 --- /dev/null +++ b/toutiao-publisher/README.md @@ -0,0 +1,40 @@ +# 头条号批量发布(toutiao-publisher) + +头条号批量发布技能仓库;当前为**占位阶段**,仅含 CLI 骨架与文档,**发布功能待后续迭代**。 + +## 目录一览 + +| 路径 | 作用 | +|------|------| +| `SKILL.md` | 技能清单(YAML 头 + Markdown 正文) | +| `release.ps1` | 本地一键发布(依赖与 `jiangchang-platform-kit` 并列,见 `account-manager` 同款) | +| `scripts/skill_main.py` | CLI 入口:`health` / `version` | +| `docs/` | 运行时与可移植性说明 | +| `optional/` | 可选片段(路径、SQLite 等),默认不引用 | + +## 本地试跑 + +```bash +python scripts/skill_main.py health +python scripts/skill_main.py version +``` + +## 版本 + +与 `SKILL.md` 中 `version` 字段对齐更新。 + +## Git 远程(避免推到模板仓) + +从 `skill-template` 克隆的目录,默认 `origin` 仍指向模板仓库。本技能应使用独立仓库,例如: + +```text +http://120.25.191.12:3000/admin/toutiao-publisher.git +``` + +在 Gitea 上**新建同名空仓库**后执行: + +```bash +git remote set-url origin http://120.25.191.12:3000/admin/toutiao-publisher.git +git push -u origin main +git push origin v1.0.1 # 若本地已有 tag 且需同步 +``` diff --git a/toutiao-publisher/SKILL.md b/toutiao-publisher/SKILL.md new file mode 100644 index 0000000..311a857 --- /dev/null +++ b/toutiao-publisher/SKILL.md @@ -0,0 +1,52 @@ +--- +# --------------------------------------------------------------------------- +# 技能清单(Skill Manifest) +# --------------------------------------------------------------------------- +name: 头条号批量发布 +description: 头条号批量发布技能(骨架阶段:仅健康检查与版本;发布逻辑待实现)。 +version: 0.1.0 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: toutiao-publisher + emoji: "📰" + category: "内容发布" + skill: + slug: toutiao-publisher + emoji: "📰" + category: "内容发布" +allowed-tools: + - bash +--- + +# 头条号批量发布(toutiao-publisher) + +## 使用时机 + +- 用户需要**在头条号侧批量或自动化发布内容**时(具体话术与流程待业务实现后补充)。 + +## 执行步骤 + +### 健康检查 + +```bash +python3 {baseDir}/scripts/skill_main.py health +``` + +### 查看版本 + +```bash +python3 {baseDir}/scripts/skill_main.py version +``` + +### 发布与其它子命令 + +实现中:后续将在 `scripts/` 下增加发布入口,并在此文档补充命令与参数说明。 + +## 环境依赖 + +详见本仓库 `docs/RUNTIME.md`(`CLAW_DATA_ROOT`、`CLAW_USER_ID` 等)。 + +## 数据与隐私 + +本技能若产生持久化数据,应仅写入 `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/toutiao-publisher/` 下;不得将用户数据提交到版本库。 diff --git a/toutiao-publisher/docs/PORTABILITY.md b/toutiao-publisher/docs/PORTABILITY.md new file mode 100644 index 0000000..af667eb --- /dev/null +++ b/toutiao-publisher/docs/PORTABILITY.md @@ -0,0 +1,56 @@ +# 多宿主(多 Claw)可移植性说明 + +## 设计目标 + +同一套技能仓库应能在**不同 Claw 实现**下工作,只要宿主满足本文档的**最小契约**:能启动进程、传入环境变量、并把 `SKILL.md` 提供给编排层阅读。 + +## 行业上常见的「技能包」形态 + +- **声明式清单**(Markdown + YAML 头):描述名称、描述、版本、工具权限、触发场景。 +- **可执行入口**:一至多个脚本/二进制,由宿主通过 `bash` / `python` 等调用。 +- **用户数据与代码分离**:持久化数据落在用户可写目录,不写在安装目录内。 + +本仓库按上述惯例组织;具体宿主如何解析 `SKILL.md` 的 YAML 键名,以各宿主文档为准。 + +## 标识技能:`metadata.skill` + +`SKILL.md` 中使用 **`metadata.skill.slug`**(及 `metadata.openclaw.slug`)作为**可移植**的机器可读标识(短横线命名,如 `toutiao-publisher`)。 + +若你的宿主仍要求其它键名(例如历史实现里的嵌套字段),请在宿主侧做**映射**,或在 `SKILL.md` 中**并列声明**两组 metadata(保持 `slug` 值一致)。不要在业务代码里写死某一宿主品牌名。 + +## 环境变量:推荐前缀 `CLAW_*` + +为减少对单一产品名的耦合,文档与可选片段推荐使用: + +| 变量 | 含义 | +|------|------| +| `CLAW_DATA_ROOT` | 用户数据根目录(多技能共享的上一级) | +| `CLAW_USER_ID` | 当前工作空间或用户标识,用于数据隔离 | +| `CLAW_SKILLS_ROOT` | 可选;多个技能并排安装时的根目录,便于 `subprocess` 调用兄弟技能 | + +宿主若已使用其它名称,推荐在**启动子进程时**注入别名,例如: + +- 将宿主内部的「数据根」映射为 `CLAW_DATA_ROOT` +- 将宿主内部「用户 ID」映射为 `CLAW_USER_ID` + +这样技能脚本无需分支判断宿主品牌。 + +## 路径布局约定(逻辑路径) + +在 `CLAW_DATA_ROOT` 与 `CLAW_USER_ID` 可用时,本技能推荐将私有数据放在: + +```text +{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/ +``` + +其中 `skill_slug` 与 `SKILL.md` 中 `metadata.skill.slug` 一致。若环境变量缺失,`optional/paths_snippet.py` 中提供了**仅用于开发机**的 fallback(见该文件注释),生产环境应由宿主注入变量。 + +## 发布与制品 + +不同组织对加密、签名、制品格式要求不同。`.github/workflows/release_skill.yaml` 仅作占位:**务必替换**为你们自己的复用工作流或删除。 + +## 自检 + +- [ ] `SKILL.md` 中 `slug` 与目录名/制品名策略是否与宿主一致 +- [ ] 宿主文档要求的环境变量是否已全部注入 +- [ ] 是否在文档中说明了「未注入变量时的行为」(拒绝运行 / 本地 fallback) diff --git a/toutiao-publisher/docs/RUNTIME.md b/toutiao-publisher/docs/RUNTIME.md new file mode 100644 index 0000000..004e8ce --- /dev/null +++ b/toutiao-publisher/docs/RUNTIME.md @@ -0,0 +1,55 @@ +# 运行时契约(环境变量与目录) + +本文档定义技能进程**建议依赖**的外部条件,便于不同 Claw 宿主统一接入。业务技能应**读取环境变量**,而不是在代码里写死路径或用户名。 + +## 必需程度说明 + +- **强烈建议**:生产环境由宿主注入;技能应对缺失给出明确错误提示,避免静默写到意外目录。 +- **可选**:没有时技能仍可部分运行(例如只读 `health`)。 + +## 变量一览 + +### `CLAW_DATA_ROOT`(强烈建议) + +用户数据根。多个技能、多个用户的数据都在此根之下分区。 + +- 典型场景:组织策略指定的盘符路径或 `~/.your-org-data`。 +- 未设置时:技能**不应**猜测网络盘;开发机 fallback 仅限 `optional/paths_snippet.py` 中说明的情形。 + +### `CLAW_USER_ID`(强烈建议) + +当前会话所代表的用户或工作空间 ID(字符串)。与数据隔离强相关。 + +- 用于拼接:`{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/` +- 未设置时:可用匿名占位(如 `_anon`)**仅用于开发**,生产应显式注入。 + +### `CLAW_SKILLS_ROOT`(可选) + +多个技能以并列目录安装时的根路径,例如: + +```text +{CLAW_SKILLS_ROOT}/skill-a/scripts/... +{CLAW_SKILLS_ROOT}/skill-b/scripts/... +``` + +编排型技能若需要通过子进程调用兄弟技能,应基于该变量定位脚本,避免写死绝对路径。 + +## 本技能推荐的数据目录 + +```text +{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/ +``` + +- `skill_slug`:与 `SKILL.md` 内 `metadata.skill.slug` 一致。 +- 在此目录下可放置 SQLite 文件、缓存、上传临时文件等;**不要**向版本库提交该目录内容。 + +## 标准输出约定(建议) + +为便于宿主与自动化解析,建议: + +- 致命错误:单行前缀 `ERROR:`,例如 `ERROR:MISSING_ENV_CLAW_DATA_ROOT` +- 成功:人类可读一行或多行;若有机读需求,可用 `JSON` 单行输出并在 `SKILL.md` 中说明格式。 + +## 与具体宿主的关系 + +若某宿主文档规定了另一套变量名,应在**宿主启动技能子进程时**注入为本文档中的 `CLAW_*` 名称,或在技能内使用一层极薄的 `getenv` 封装(见 `optional/paths_snippet.py` 注释示例)。**不要在业务模块中散落多套变量名判断。** diff --git a/toutiao-publisher/docs/SKILL_TYPES.md b/toutiao-publisher/docs/SKILL_TYPES.md new file mode 100644 index 0000000..80c8590 --- /dev/null +++ b/toutiao-publisher/docs/SKILL_TYPES.md @@ -0,0 +1,33 @@ +# 技能形态与自检清单(无业务) + +开发前先选定形态,避免把「编排、存储、浏览器」混在一个脚本里难以测试。 + +## 类型 A:无状态工具型 + +- **特征**:不持久化用户数据,或只读配置文件;输入输出主要在 stdin/stdout。 +- **数据目录**:通常不需要 `CLAW_DATA_ROOT` 下的专属库;若需要缓存,仍建议放在契约目录下。 +- **自检**:离线可跑;`health` 不访问网络也可成功。 + +## 类型 B:本地持久化型 + +- **特征**:使用 SQLite、本地文件等保存用户数据。 +- **数据目录**:必须使用 `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/`。 +- **自检**:首次运行自动建库/建表;文档中说明库文件路径与备份方式。 + +## 类型 C:编排型(调用其它技能或外部 CLI) + +- **特征**:自身逻辑薄,主要 `subprocess` 或 HTTP 调用其它组件。 +- **依赖**:在 `SKILL.md` 中明确写出**先决条件**(兄弟技能已安装、某 CLI 在 PATH 等)。 +- **自检**:`health` 可检查兄弟可执行文件是否存在;缺失时打印清晰错误。 + +## 类型 D:混合型 + +- **特征**:既有本地存储,又调用外部能力。 +- **建议**:拆模块(存储 / 编排 / 领域逻辑),入口脚本只做参数解析与调度。 + +## 发布前通用自检 + +- [ ] `SKILL.md` 中触发条件与示例命令与实际入口一致 +- [ ] 未注入 `CLAW_DATA_ROOT` / `CLAW_USER_ID` 时行为已文档化 +- [ ] 不向仓库提交用户数据、密钥、大型二进制 +- [ ] 错误信息包含「如何修复」(缺什么环境变量、缺哪个依赖) diff --git a/toutiao-publisher/optional/README.md b/toutiao-publisher/optional/README.md new file mode 100644 index 0000000..8ef3ac9 --- /dev/null +++ b/toutiao-publisher/optional/README.md @@ -0,0 +1,17 @@ +# optional/ 目录说明 + +本目录下的文件**不会**被 `scripts/skill_main.py` 自动引用。 + +## 为什么要单独放 + +- 避免可选片段拖慢最小 `health` 起步,按需再复制进业务代码。 +- 需要时**整文件复制**到 `scripts/` 或你们自己的包路径下,再按文件头注释改名、改常量。 + +## 文件列表 + +| 文件 | 用途 | +|------|------| +| `paths_snippet.py` | `CLAW_*` 数据目录解析与 fallback 说明 | +| `sqlite_minimal.py` | 无业务含义的 SQLite 建表示例 | + +本技能数据子目录与 `SKILL_SLUG` 为 `toutiao-publisher`;若复制片段到其它项目请改为对应 slug 与表名。 diff --git a/toutiao-publisher/optional/paths_snippet.py b/toutiao-publisher/optional/paths_snippet.py new file mode 100644 index 0000000..f906886 --- /dev/null +++ b/toutiao-publisher/optional/paths_snippet.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +""" +可选片段:路径与环境变量(请复制到 scripts/ 或作为独立模块使用) +================================================================ + +【用途】 + 统一解析「用户数据根」「用户 ID」「本技能私有目录」,避免在业务代码里重复 os.getenv。 + +【使用步骤】 + 1. 将本文件复制到 skills/your-slug/scripts/paths.py(或任意模块名)。 + 2. 把 SKILL_SLUG 改为与 SKILL.md 中 metadata.skill.slug 一致。 + 3. 在业务代码中: from paths import get_skill_data_dir (按实际包路径调整) + +【可移植性】 + - 优先读取标准名 CLAW_*(见 docs/RUNTIME.md)。 + - 若你的组织在宿主侧仍使用历史变量名,可在此文件 _aliases 列表中追加 (标准名, 备选名), + 由宿主注入其一即可;不要在业务里再写第三套名字。 + +【注意】 + - 未设置 CLAW_DATA_ROOT 时的 fallback 仅适合开发机;生产环境应由宿主注入。 +""" + +from __future__ import annotations + +import os +import sys + +# TODO: 复制本文件后改为你的 slug(与 SKILL.md metadata.skill.slug 一致) +SKILL_SLUG = "toutiao-publisher" + + +def _getenv_first(names: tuple[str, ...]) -> str: + """按顺序读取多个环境变量名,返回第一个非空值。""" + for n in names: + v = (os.getenv(n) or "").strip() + if v: + return v + return "" + + +def get_data_root() -> str: + """ + 用户数据根目录。 + 顺序:CLAW_DATA_ROOT → (可选)宿主别名,见下方元组。 + 若皆空:Windows 默认 D:\\claw-data;其它系统默认 ~/.claw-data —— 仅开发便利,生产请注入 CLAW_DATA_ROOT。 + """ + root = _getenv_first( + ( + "CLAW_DATA_ROOT", + # 在此追加组织内别名,例如 "MYORG_USER_DATA_ROOT", + ) + ) + if root: + return root + if sys.platform == "win32": + return r"D:\claw-data" + return os.path.join(os.path.expanduser("~"), ".claw-data") + + +def get_user_id() -> str: + """当前用户或工作空间 ID;未设置时用 _anon(仅开发)。""" + uid = _getenv_first( + ( + "CLAW_USER_ID", + # 在此追加别名,例如 "MYORG_WORKSPACE_ID", + ) + ) + return uid or "_anon" + + +def get_skill_data_dir() -> str: + """ + 本技能可写目录:{数据根}/{用户ID}/{skill_slug}/ + 会自动 os.makedirs(..., exist_ok=True)。 + """ + path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) + os.makedirs(path, exist_ok=True) + return path + + +def get_skills_root() -> str: + """ + 可选:并列安装的多技能根目录。 + 未设置 CLAW_SKILLS_ROOT 时,默认使用本文件所在仓库的上一级目录的上一级 + (即:.../toutiao-publisher/optional/ → 仅作开发时并列技能推断参考)。 + """ + root = _getenv_first(("CLAW_SKILLS_ROOT",)) + if root: + return root + # optional/ 下:仓库根为 dirname(dirname(__file__)) + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.dirname(here) diff --git a/toutiao-publisher/optional/sqlite_minimal.py b/toutiao-publisher/optional/sqlite_minimal.py new file mode 100644 index 0000000..3206fa8 --- /dev/null +++ b/toutiao-publisher/optional/sqlite_minimal.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" +可选片段:最小 SQLite 示例(请复制后按需修改) +============================================== + +【用途】 + 演示「单表 + 自增主键 + INTEGER 时间戳」的一种严谨写法;与具体业务无关。 + +【使用步骤】 + 1. 复制到 scripts/db_example.py(或并入你的模块)。 + 2. 修改 TABLE_SQL 中的表名与字段;保持时间字段为 INTEGER Unix 秒(UTC)若需跨时区一致。 + 3. 在入口脚本中仅在需要持久化时 import。 + +【注意】 + - 本示例不做迁移兼容;schema 变更请用你们组织的迁移策略。 + - 数据库文件路径建议:get_skill_data_dir() / "skill.db"(paths_snippet 中函数)。 +""" + +from __future__ import annotations + +import sqlite3 +import time + +# 示例表:与任何业务无关 +TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS skill_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键 + action TEXT NOT NULL, -- 动作标识,如 health_ok + created_at INTEGER NOT NULL -- Unix 秒 UTC +); +""" + + +def connect(db_path: str) -> sqlite3.Connection: + return sqlite3.connect(db_path) + + +def init_db(conn: sqlite3.Connection) -> None: + conn.execute(TABLE_SQL) + conn.commit() + + +def record_action(conn: sqlite3.Connection, action: str) -> None: + conn.execute( + "INSERT INTO skill_audit (action, created_at) VALUES (?, ?)", + (action, int(time.time())), + ) + conn.commit() diff --git a/toutiao-publisher/release.ps1 b/toutiao-publisher/release.ps1 new file mode 100644 index 0000000..639081a --- /dev/null +++ b/toutiao-publisher/release.ps1 @@ -0,0 +1,23 @@ +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message = "正式发布", + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" +$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) + +if (-not (Test-Path $sharedScript)) { + throw "Shared release script not found: $sharedScript" +} + +& $sharedScript @PSBoundParameters +exit $LASTEXITCODE diff --git a/toutiao-publisher/scripts/skill_main.py b/toutiao-publisher/scripts/skill_main.py new file mode 100644 index 0000000..b8db6ab --- /dev/null +++ b/toutiao-publisher/scripts/skill_main.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +头条号发布技能 — CLI 入口 +======================== + +【职责】 + - 作为宿主调用时的统一 CLI 入口(子进程、终端、CI 均可)。 + - 只做:参数解析、环境检查、分发到具体子命令;复杂逻辑放到同目录其它模块。 + +【如何扩展】 + 1. 在 main() 的 dispatch 字典中增加 "your_cmd": handler 项。 + 2. 实现 handler(argv) 或 handler();出错时打印 ERROR: 前缀信息并 sys.exit(非0)。 + 3. 在仓库根目录 SKILL.md「执行步骤」中补充示例命令。 + +【多宿主注意】 + - 不要在本文件写死某一品牌宿主名。 + - 路径与环境变量约定见 ../docs/RUNTIME.md;可选辅助代码见 ../optional/paths_snippet.py(需自行复制或 import 路径按项目调整)。 + +【编码】 + Windows 下若宿主仍使用系统默认编码,可在宿主侧设置 UTF-8;此处不强制改 sys.stdout(避免与宿主捕获冲突)。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Callable, Dict, List, Optional + +# 与 SKILL.md 中 metadata.openclaw.slug / metadata.skill.slug 保持一致 +SKILL_SLUG = "toutiao-publisher" + + +def cmd_version(_args: argparse.Namespace) -> int: + """打印版本信息(与 SKILL.md frontmatter 中 version 应对齐,此处为占位)。""" + payload = { + "skill_slug": SKILL_SLUG, + "version": "0.1.0", + "entry": "skill_main.py", + } + print(json.dumps(payload, ensure_ascii=False)) + return 0 + + +def cmd_health(_args: argparse.Namespace) -> int: + """ + 健康检查:应快速、可离线(除非技能本身强依赖网络)。 + 失败时打印 ERROR: 前缀,便于宿主与自动化解析。 + """ + # 示例:检查 Python 版本(可按需改为检查关键依赖 import) + if sys.version_info < (3, 9): + print("ERROR:PYTHON_VERSION need >= 3.9", file=sys.stderr) + return 1 + print(f"OK skill={SKILL_SLUG} python={sys.version.split()[0]}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="toutiao-publisher — Toutiao batch publish skill CLI (skeleton).", + ) + sub = p.add_subparsers(dest="command", required=True) + + sp = sub.add_parser("version", help="Print version JSON.") + sp.set_defaults(handler=cmd_version) + + sp = sub.add_parser("health", help="Quick health check.") + sp.set_defaults(handler=cmd_health) + + return p + + +def main(argv: Optional[List[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + parser = build_parser() + args = parser.parse_args(argv) + handler: Callable[[argparse.Namespace], int] = args.handler + return handler(args) + + +if __name__ == "__main__": + raise SystemExit(main())