Add OpenClaw skills, platform kit, and template docs

Made-with: Cursor
This commit is contained in:
2026-04-04 10:35:02 +08:00
parent e37b03c00f
commit 35f4758da2
83 changed files with 8971 additions and 0 deletions

View File

@@ -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 \"测试\")"
]
}
}

112
CLAUDE.md Normal file
View File

@@ -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/<slug>.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 <id>
# API key vault
python api-key-vault/scripts/vault.py get 17track
# Logistics
python logistics-tracker/scripts/main.py <tracking_number>
# Sohu publish
python sohu-publisher/scripts/main.py <account_id> <article_id>
```
## 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 AD 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.

Binary file not shown.

View File

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

53
account-manager/SKILL.md Normal file
View File

@@ -0,0 +1,53 @@
---
name: 账号管理
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。
version: 1.0.4
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: account-manager
emoji: "👤"
category: "通用"
allowed-tools:
- bash
---
# 账号管理
## 使用时机
当用户发送以下内容的时候触发本Skill
- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号"
- 说"列出账号"、"查看所有账号"、"有哪些账号"
- 说"添加账号"、"删除账号"、"获取账号信息"
## 执行步骤
### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号同平台下同手机号不可重复ID 自增名称如「搜狐1号」
```bash
python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx
python3 {baseDir}/scripts/main.py add 知乎 13800138000
```
支持的平台称呼示例搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝亦支持英文键如 sohu、toutiao
### 仅打开浏览器核对是否已登录(不写数据库)
```bash
python3 {baseDir}/scripts/main.py open <id>
```
### 登录并自动检测、写入数据库
```bash
python3 {baseDir}/scripts/main.py login <id>
```
### 删除账号(同时删库里的记录与 profile 用户数据目录)
```bash
python3 {baseDir}/scripts/main.py delete id <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 搜狐号

View File

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

File diff suppressed because it is too large Load Diff

View File

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

54
api-key-vault/SKILL.md Normal file
View File

@@ -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命令获取不要硬编码

220
api-key-vault/release.ps1 Normal file
View File

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

View File

@@ -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 <key名>")
print(" python vault.py set <key名> <key值>")
print(" python vault.py list")
print(" python vault.py delete <key名>")
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)

View File

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

82
content-manager/SKILL.md Normal file
View File

@@ -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/<id>/`
- **视频** 表 `videos`:同上,文件在 `{…}/content-manager/videos/<id>/`;可选 `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 <id>
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 <id>
python {baseDir}/scripts/main.py article feedback <id> published <account_id>
python {baseDir}/scripts/main.py article save <id或占位> <title> <单行正文>
```
`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 配置。

View File

@@ -0,0 +1 @@
# content-manager 技能:文章 / 图片 / 视频 分层包

View File

@@ -0,0 +1 @@
# CLI解析参数并调用 services

View File

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

View File

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

View File

@@ -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}”撰写可直接发公众号的文章,要求原创、完整、可读。",
],
}

View File

@@ -0,0 +1,3 @@
from content_manager.db.connection import get_conn, init_db
__all__ = ["get_conn", "init_db"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
# 业务逻辑层(调用 db 仓储,不含 argparse

View File

@@ -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("✅ 状态已更新")

View File

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

View File

@@ -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("✅ 状态已更新")

View File

@@ -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("✅ 状态已更新")

View File

@@ -0,0 +1 @@
# 工具函数

View File

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

View File

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

220
content-manager/release.ps1 Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
from .client import EntitlementClient
from .guard import enforce_entitlement
from .models import EntitlementResult
__all__ = [
"EntitlementClient",
"EntitlementResult",
"enforce_entitlement",
]

View File

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

View File

@@ -0,0 +1,10 @@
class EntitlementError(Exception):
pass
class EntitlementDeniedError(EntitlementError):
pass
class EntitlementServiceError(EntitlementError):
pass

View File

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

View File

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

View File

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

View File

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

View File

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

119
llm-manager/SKILL.md Normal file
View File

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

220
llm-manager/release.ps1 Normal file
View File

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

292
llm-manager/scripts/db.py Normal file
View File

@@ -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, -- 平台 slugdoubao/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:]

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
"""
DeepSeek 网页版驱动引擎chat.deepseek.com
选择器说明(如页面改版需更新):
- 输入框:#chat-inputtextarea
- 发送按钮:[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}"

View File

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

View File

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

View File

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

View File

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

View File

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

575
llm-manager/scripts/main.py Normal file
View File

@@ -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 KeyID {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 Keypython 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_typeapi_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_typeweb_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())

View File

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

View File

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

View File

@@ -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次查询正式使用需升级

View File

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

View File

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

View File

@@ -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` | 常见技能形态与自检清单 |

View File

@@ -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` 即可。

View File

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

View File

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

77
sohu-publisher/SKILL.md Normal file
View File

@@ -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` 警告和一系列提示。这说明你成功启动了代理人流程,但搜狐账号已经掉线了!这时脚本实际上已经替您调起了浏览器的登录弹窗。
> 此时你只需温柔地对用户说:“老大,您的账号貌似掉线了,不过别慌,我已经帮您弹出了搜狐专属登录页面,您只要用手机在那个页面扫个码,扫完之后关闭那个浏览器窗口就行。等您弄好了告诉我,我再帮您从头发布一次!”

220
sohu-publisher/release.ps1 Normal file
View File

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

View File

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

View File

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

8
toutiao-publisher/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.pyo
.Python
.venv/
venv/
.env
.env.*

View File

@@ -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 且需同步
```

View File

@@ -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/` 下;不得将用户数据提交到版本库。

View File

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

View File

@@ -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` 注释示例)。**不要在业务模块中散落多套变量名判断。**

View File

@@ -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` 时行为已文档化
- [ ] 不向仓库提交用户数据、密钥、大型二进制
- [ ] 错误信息包含「如何修复」(缺什么环境变量、缺哪个依赖)

View File

@@ -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 与表名。

View File

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

View File

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

View File

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

View File

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