Add OpenClaw skills, platform kit, and template docs
Made-with: Cursor
This commit is contained in:
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal 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
112
CLAUDE.md
Normal 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 A–D classification checklist |
|
||||
| `skill-template/docs/PORTABILITY.md` | Cross-platform encoding, path differences |
|
||||
| `skill-template/docs/LOGGING.md` | `TimedRotatingFileHandler` setup |
|
||||
|
||||
## Known Dev-Mode Issues
|
||||
|
||||
- `account-manager/scripts/main.py` has `_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True` hardcoded with a fixed data path (`D:\jiangchang-data`) and user ID — disable before releasing.
|
||||
- `toutiao-publisher` is a placeholder stub; publish logic is not yet implemented.
|
||||
BIN
__pycache__/mcp_server.cpython-312.pyc
Normal file
BIN
__pycache__/mcp_server.cpython-312.pyc
Normal file
Binary file not shown.
11
account-manager/.github/workflows/release_skill.yaml
vendored
Normal file
11
account-manager/.github/workflows/release_skill.yaml
vendored
Normal 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
53
account-manager/SKILL.md
Normal 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 搜狐号
|
||||
23
account-manager/release.ps1
Normal file
23
account-manager/release.ps1
Normal 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
|
||||
1498
account-manager/scripts/main.py
Normal file
1498
account-manager/scripts/main.py
Normal file
File diff suppressed because it is too large
Load Diff
11
api-key-vault/.github/workflows/release_skill.yaml
vendored
Normal file
11
api-key-vault/.github/workflows/release_skill.yaml
vendored
Normal 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
54
api-key-vault/SKILL.md
Normal 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
220
api-key-vault/release.ps1
Normal 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
|
||||
}
|
||||
105
api-key-vault/scripts/vault.py
Normal file
105
api-key-vault/scripts/vault.py
Normal 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)
|
||||
11
content-manager/.github/workflows/release_skill.yaml
vendored
Normal file
11
content-manager/.github/workflows/release_skill.yaml
vendored
Normal 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
82
content-manager/SKILL.md
Normal 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 配置。
|
||||
1
content-manager/content_manager/__init__.py
Normal file
1
content-manager/content_manager/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# content-manager 技能:文章 / 图片 / 视频 分层包
|
||||
1
content-manager/content_manager/cli/__init__.py
Normal file
1
content-manager/content_manager/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# CLI:解析参数并调用 services
|
||||
261
content-manager/content_manager/cli/app.py
Normal file
261
content-manager/content_manager/cli/app.py
Normal 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
|
||||
47
content-manager/content_manager/config.py
Normal file
47
content-manager/content_manager/config.py
Normal 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))
|
||||
103
content-manager/content_manager/constants.py
Normal file
103
content-manager/content_manager/constants.py
Normal 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}”撰写可直接发公众号的文章,要求原创、完整、可读。",
|
||||
],
|
||||
}
|
||||
3
content-manager/content_manager/db/__init__.py
Normal file
3
content-manager/content_manager/db/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
|
||||
__all__ = ["get_conn", "init_db"]
|
||||
122
content-manager/content_manager/db/articles_repository.py
Normal file
122
content-manager/content_manager/db/articles_repository.py
Normal 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),
|
||||
)
|
||||
113
content-manager/content_manager/db/connection.py
Normal file
113
content-manager/content_manager/db/connection.py
Normal 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()
|
||||
88
content-manager/content_manager/db/images_repository.py
Normal file
88
content-manager/content_manager/db/images_repository.py
Normal 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),
|
||||
)
|
||||
114
content-manager/content_manager/db/prompts_repository.py
Normal file
114
content-manager/content_manager/db/prompts_repository.py
Normal 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())
|
||||
73
content-manager/content_manager/db/schema.py
Normal file
73
content-manager/content_manager/db/schema.py
Normal 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
|
||||
);
|
||||
"""
|
||||
100
content-manager/content_manager/db/videos_repository.py
Normal file
100
content-manager/content_manager/db/videos_repository.py
Normal 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),
|
||||
)
|
||||
1
content-manager/content_manager/services/__init__.py
Normal file
1
content-manager/content_manager/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 业务逻辑层(调用 db 仓储,不含 argparse)
|
||||
431
content-manager/content_manager/services/article_service.py
Normal file
431
content-manager/content_manager/services/article_service.py
Normal 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("✅ 状态已更新")
|
||||
50
content-manager/content_manager/services/file_store.py
Normal file
50
content-manager/content_manager/services/file_store.py
Normal 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)
|
||||
171
content-manager/content_manager/services/image_service.py
Normal file
171
content-manager/content_manager/services/image_service.py
Normal 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("✅ 状态已更新")
|
||||
179
content-manager/content_manager/services/video_service.py
Normal file
179
content-manager/content_manager/services/video_service.py
Normal 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("✅ 状态已更新")
|
||||
1
content-manager/content_manager/util/__init__.py
Normal file
1
content-manager/content_manager/util/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 工具函数
|
||||
74
content-manager/content_manager/util/argparse_zh.py
Normal file
74
content-manager/content_manager/util/argparse_zh.py
Normal 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)
|
||||
36
content-manager/content_manager/util/timeutil.py
Normal file
36
content-manager/content_manager/util/timeutil.py
Normal 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
220
content-manager/release.ps1
Normal 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
|
||||
}
|
||||
24
content-manager/scripts/main.py
Normal file
24
content-manager/scripts/main.py
Normal 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())
|
||||
139
jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml
vendored
Normal file
139
jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml
vendored
Normal 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)
|
||||
"
|
||||
7
jiangchang-platform-kit/README.md
Normal file
7
jiangchang-platform-kit/README.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
from .client import EntitlementClient
|
||||
from .guard import enforce_entitlement
|
||||
from .models import EntitlementResult
|
||||
|
||||
__all__ = [
|
||||
"EntitlementClient",
|
||||
"EntitlementResult",
|
||||
"enforce_entitlement",
|
||||
]
|
||||
63
jiangchang-platform-kit/sdk/jiangchang_skill_core/client.py
Normal file
63
jiangchang-platform-kit/sdk/jiangchang_skill_core/client.py
Normal 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)
|
||||
10
jiangchang-platform-kit/sdk/jiangchang_skill_core/errors.py
Normal file
10
jiangchang-platform-kit/sdk/jiangchang_skill_core/errors.py
Normal file
@@ -0,0 +1,10 @@
|
||||
class EntitlementError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EntitlementDeniedError(EntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class EntitlementServiceError(EntitlementError):
|
||||
pass
|
||||
22
jiangchang-platform-kit/sdk/jiangchang_skill_core/guard.py
Normal file
22
jiangchang-platform-kit/sdk/jiangchang_skill_core/guard.py
Normal 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
|
||||
10
jiangchang-platform-kit/sdk/jiangchang_skill_core/models.py
Normal file
10
jiangchang-platform-kit/sdk/jiangchang_skill_core/models.py
Normal 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
|
||||
19
jiangchang-platform-kit/sdk/pyproject.toml
Normal file
19
jiangchang-platform-kit/sdk/pyproject.toml
Normal 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*"]
|
||||
216
jiangchang-platform-kit/tools/release.ps1
Normal file
216
jiangchang-platform-kit/tools/release.ps1
Normal 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
|
||||
}
|
||||
11
llm-manager/.github/workflows/release_skill.yaml
vendored
Normal file
11
llm-manager/.github/workflows/release_skill.yaml
vendored
Normal 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
119
llm-manager/SKILL.md
Normal 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
220
llm-manager/release.ps1
Normal 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
292
llm-manager/scripts/db.py
Normal 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, -- 平台 slug:doubao/deepseek/qianwen/kimi/yiyan/yuanbao
|
||||
label TEXT NOT NULL DEFAULT '', -- 用户自定义备注,如「公司Key」
|
||||
api_key TEXT NOT NULL, -- API Key 原文
|
||||
default_model TEXT, -- 默认模型(doubao 须填 ep-xxx)
|
||||
is_active INTEGER NOT NULL DEFAULT 1, -- 是否启用:0 停用 1 启用
|
||||
last_used_at INTEGER, -- 最近调用时间,Unix 秒;从未用过为 NULL
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
LLM_WEB_ACCOUNTS_TABLE_SQL = """
|
||||
CREATE TABLE llm_web_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
account_name TEXT NOT NULL DEFAULT '',
|
||||
login_status INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(provider, account_id)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _now_unix() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def get_skill_data_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_db_path() -> str:
|
||||
return os.path.join(get_skill_data_dir(), f"{SKILL_SLUG}.db")
|
||||
|
||||
|
||||
def get_conn():
|
||||
return sqlite3.connect(get_db_path())
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='llm_keys'")
|
||||
if not cur.fetchone():
|
||||
cur.executescript(LLM_KEYS_TABLE_SQL)
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='llm_web_accounts'")
|
||||
if not cur.fetchone():
|
||||
cur.executescript(LLM_WEB_ACCOUNTS_TABLE_SQL)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_key(provider: str, api_key: str, model: Optional[str] = None, label: str = "") -> int:
|
||||
init_db()
|
||||
now = _now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO llm_keys (provider, label, api_key, default_model, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
""",
|
||||
(provider, label or "", api_key, model, now, now),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return new_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_web_account(provider: str, account_id: int, account_name: str = "", login_status: int = 1) -> int:
|
||||
init_db()
|
||||
now = _now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO llm_web_accounts (provider, account_id, account_name, login_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, account_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
login_status = excluded.login_status,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(provider, int(account_id), account_name or "", int(login_status or 0), now, now),
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute(
|
||||
"SELECT id FROM llm_web_accounts WHERE provider = ? AND account_id = ?",
|
||||
(provider, int(account_id)),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_keys(provider: Optional[str] = None, limit: int = 10) -> list:
|
||||
init_db()
|
||||
if not isinstance(limit, int) or limit <= 0:
|
||||
limit = 10
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if provider:
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at "
|
||||
"FROM llm_keys WHERE provider = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(provider, limit),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at "
|
||||
"FROM llm_keys ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"label": row[2] or "",
|
||||
"api_key": row[3],
|
||||
"default_model": row[4] or "",
|
||||
"is_active": row[5],
|
||||
"last_used_at": row[6],
|
||||
"created_at": row[7],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def list_web_accounts(provider: Optional[str] = None, limit: int = 10) -> list:
|
||||
init_db()
|
||||
if not isinstance(limit, int) or limit <= 0:
|
||||
limit = 10
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if provider:
|
||||
cur.execute(
|
||||
"SELECT id, provider, account_id, account_name, login_status, created_at, updated_at "
|
||||
"FROM llm_web_accounts WHERE provider = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(provider, limit),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id, provider, account_id, account_name, login_status, created_at, updated_at "
|
||||
"FROM llm_web_accounts ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"account_id": row[2],
|
||||
"account_name": row[3] or "",
|
||||
"login_status": int(row[4] or 0),
|
||||
"created_at": row[5],
|
||||
"updated_at": row[6],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_key_by_id(key_id: int) -> Optional[dict]:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at "
|
||||
"FROM llm_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"label": row[2] or "",
|
||||
"api_key": row[3],
|
||||
"default_model": row[4] or "",
|
||||
"is_active": row[5],
|
||||
"last_used_at": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_key(key_id: int) -> bool:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM llm_keys WHERE id = ?", (key_id,))
|
||||
if not cur.fetchone():
|
||||
return False
|
||||
cur.execute("DELETE FROM llm_keys WHERE id = ?", (key_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_key(provider: str) -> Optional[dict]:
|
||||
"""查找该平台第一个 is_active=1 的 key(按 id 升序)。"""
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at "
|
||||
"FROM llm_keys WHERE provider = ? AND is_active = 1 ORDER BY id LIMIT 1",
|
||||
(provider,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"label": row[2] or "",
|
||||
"api_key": row[3],
|
||||
"default_model": row[4] or "",
|
||||
"is_active": row[5],
|
||||
"last_used_at": row[6],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def mark_key_used(key_id: int):
|
||||
now = _now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE llm_keys SET last_used_at = ?, updated_at = ? WHERE id = ?",
|
||||
(now, now, key_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _mask_key(api_key: str) -> str:
|
||||
"""展示时打码:前4位 + ... + 后4位。"""
|
||||
k = api_key or ""
|
||||
if len(k) <= 8:
|
||||
return k[:2] + "****"
|
||||
return k[:4] + "..." + k[-4:]
|
||||
28
llm-manager/scripts/engines/api_engine.py
Normal file
28
llm-manager/scripts/engines/api_engine.py
Normal 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}"
|
||||
15
llm-manager/scripts/engines/base.py
Normal file
15
llm-manager/scripts/engines/base.py
Normal 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
|
||||
70
llm-manager/scripts/engines/deepseek.py
Normal file
70
llm-manager/scripts/engines/deepseek.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
DeepSeek 网页版驱动引擎(chat.deepseek.com)。
|
||||
|
||||
选择器说明(如页面改版需更新):
|
||||
- 输入框:#chat-input(textarea)
|
||||
- 发送按钮:[aria-label="发送消息"] 或 div[class*="send"] > button
|
||||
- 停止生成按钮:[aria-label="停止生成"](生成中可见,生成结束消失)
|
||||
- 复制按钮:最后一条回复下的 [aria-label="复制"]
|
||||
"""
|
||||
import asyncio
|
||||
from .base import BaseEngine
|
||||
|
||||
|
||||
class DeepSeekEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
await self.page.goto("https://chat.deepseek.com")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# 登录检测:若能找到输入框则已登录
|
||||
try:
|
||||
editor = self.page.locator("textarea#chat-input").first
|
||||
await editor.wait_for(state="visible", timeout=10000)
|
||||
except Exception:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
# 输入提示词
|
||||
await editor.click()
|
||||
await self.page.keyboard.insert_text(prompt)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 发送(优先点按钮,失败则按 Enter)
|
||||
sent = False
|
||||
for sel in ('[aria-label="发送消息"]', 'div[class*="send"] > button', 'button[type="submit"]'):
|
||||
try:
|
||||
btn = self.page.locator(sel).first
|
||||
if await btn.is_visible(timeout=1000):
|
||||
await btn.click()
|
||||
sent = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not sent:
|
||||
await self.page.keyboard.press("Enter")
|
||||
|
||||
print("💡 [llm-manager/deepseek] 已发送提示词,等待 DeepSeek 生成响应...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待生成完毕:停止按钮消失即为完成,超时 150 秒
|
||||
stop_sel = '[aria-label="停止生成"]'
|
||||
deadline = asyncio.get_event_loop().time() + 150
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
try:
|
||||
visible = await self.page.locator(stop_sel).first.is_visible(timeout=500)
|
||||
if not visible:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 通过最后一个复制按钮取结果
|
||||
try:
|
||||
copy_btn = self.page.locator('[aria-label="复制"]').last
|
||||
await copy_btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
result = await self.page.evaluate("navigator.clipboard.readText()")
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"ERROR:抓取 DeepSeek 内容时异常:{e}"
|
||||
222
llm-manager/scripts/engines/doubao.py
Normal file
222
llm-manager/scripts/engines/doubao.py
Normal 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
|
||||
48
llm-manager/scripts/engines/kimi.py
Normal file
48
llm-manager/scripts/engines/kimi.py
Normal 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}"
|
||||
91
llm-manager/scripts/engines/qianwen.py
Normal file
91
llm-manager/scripts/engines/qianwen.py
Normal 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}"
|
||||
91
llm-manager/scripts/engines/yiyan.py
Normal file
91
llm-manager/scripts/engines/yiyan.py
Normal 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}"
|
||||
94
llm-manager/scripts/engines/yuanbao.py
Normal file
94
llm-manager/scripts/engines/yuanbao.py
Normal 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
575
llm-manager/scripts/main.py
Normal 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 Key:ID {new_id} | {provider['label']} | 模型: {model or provider.get('api_model') or '(未指定)'} | {_mask_key(api_key)}")
|
||||
if not model and not provider.get("api_model"):
|
||||
print(f"⚠️ 注意:该平台需要指定模型名称,否则调用时会报错。")
|
||||
print(f" {provider.get('api_note', '')}")
|
||||
print(f" 可删除后重新添加:python main.py key del {new_id}")
|
||||
|
||||
|
||||
def cmd_key_list(provider_input: str = None, limit: int = 10):
|
||||
provider_key = None
|
||||
if provider_input:
|
||||
provider_key = resolve_provider_key(provider_input)
|
||||
if not provider_key:
|
||||
print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{provider_input}」。")
|
||||
return
|
||||
|
||||
keys = list_keys(provider_key, limit=limit)
|
||||
web_accounts = list_web_accounts(provider_key, limit=limit)
|
||||
if not keys and not web_accounts:
|
||||
print("暂无记录(API Key / 网页账号关联 都为空)。")
|
||||
print("添加 API Key:python main.py add <platform> \"API Key\" [--model 模型名]")
|
||||
print("添加网页账号关联:python main.py add <platform>")
|
||||
return
|
||||
|
||||
sep_line = "_" * 39
|
||||
rows = []
|
||||
for k in keys:
|
||||
rows.append({
|
||||
"type": "api_key",
|
||||
"created_at": int(k.get("created_at") or 0),
|
||||
"payload": k,
|
||||
})
|
||||
for w in web_accounts:
|
||||
rows.append({
|
||||
"type": "web_account",
|
||||
"created_at": int(w.get("created_at") or 0),
|
||||
"payload": w,
|
||||
})
|
||||
rows.sort(key=lambda x: (x["created_at"], int(x["payload"].get("id") or 0)), reverse=True)
|
||||
|
||||
for idx, row in enumerate(rows):
|
||||
if row["type"] == "api_key":
|
||||
k = row["payload"]
|
||||
print("record_type:api_key")
|
||||
print(f"id:{k['id']}")
|
||||
print(f"platform:{k['provider']}")
|
||||
print(f"platform_cn:{LLM_PROVIDERS.get(k['provider'], {}).get('label', k['provider'])}")
|
||||
print(f"label:{k.get('label') or ''}")
|
||||
print(f"api_key:{_mask_key(k.get('api_key') or '')}")
|
||||
print(f"default_model:{k.get('default_model') or ''}")
|
||||
print(f"is_active:{int(k.get('is_active') or 0)}")
|
||||
print(f"last_used_at:{_unix_to_iso(k.get('last_used_at'))}")
|
||||
print(f"created_at:{_unix_to_iso(k.get('created_at'))}")
|
||||
else:
|
||||
w = row["payload"]
|
||||
print("record_type:web_account")
|
||||
print(f"id:{w['id']}")
|
||||
print(f"platform:{w['provider']}")
|
||||
print(f"platform_cn:{LLM_PROVIDERS.get(w['provider'], {}).get('label', w['provider'])}")
|
||||
print(f"account_id:{w.get('account_id')}")
|
||||
print(f"account_name:{w.get('account_name') or ''}")
|
||||
print(f"login_status:{int(w.get('login_status') or 0)}")
|
||||
print(f"created_at:{_unix_to_iso(w.get('created_at'))}")
|
||||
print(f"updated_at:{_unix_to_iso(w.get('updated_at'))}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep_line)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_key_del(key_id_str: str):
|
||||
if not key_id_str.isdigit():
|
||||
print(f"ERROR:INVALID_ID key_id 须为正整数,收到「{key_id_str}」。")
|
||||
return
|
||||
key_id = int(key_id_str)
|
||||
key = get_key_by_id(key_id)
|
||||
if not key:
|
||||
print(f"ERROR:KEY_NOT_FOUND 未找到 ID={key_id} 的 API Key。")
|
||||
return
|
||||
delete_key(key_id)
|
||||
label_str = f"({key['label']})" if key.get("label") else ""
|
||||
print(f"✅ 已删除:ID {key_id} | {LLM_PROVIDERS.get(key['provider'], {}).get('label', key['provider'])}{label_str} | {_mask_key(key['api_key'])}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# health / version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_health():
|
||||
ok = sys.version_info >= (3, 10)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
def cmd_version():
|
||||
print(json.dumps({"version": SKILL_VERSION, "skill": "llm-manager"}, ensure_ascii=False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI 解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _print_usage():
|
||||
print("用法:")
|
||||
print(" python main.py health")
|
||||
print(" python main.py version")
|
||||
print(" python main.py generate <平台名或account_id> \"<提示词>\"")
|
||||
print(" python main.py add <平台> [API_Key] [--model 模型名] [--label 备注]")
|
||||
print(" python main.py list [平台] [--limit 条数]")
|
||||
print(" python main.py del <key_id>")
|
||||
print()
|
||||
print("支持的平台:" + provider_list_cn())
|
||||
print()
|
||||
print("generate 说明:")
|
||||
print(" · 传入 account_id(数字)→ 指定账号网页模式(需先用 account-manager 登录)")
|
||||
print(" · 传入平台名 → 自动选择:优先网页模式(免费),无可用账号时才用 API Key(付费)")
|
||||
print()
|
||||
print("兼容说明:key add/list/del 旧写法仍可用。")
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
if not args:
|
||||
_print_usage()
|
||||
return 1
|
||||
if args[0] in ("-h", "--help"):
|
||||
_print_usage()
|
||||
return 0
|
||||
|
||||
def _parse_list_args(rest_args):
|
||||
platform_filter = None
|
||||
limit = 10
|
||||
i = 0
|
||||
while i < len(rest_args):
|
||||
if rest_args[i] == "--limit" and i + 1 < len(rest_args):
|
||||
try:
|
||||
limit = int(rest_args[i + 1])
|
||||
except ValueError:
|
||||
print(f"ERROR:CLI_KEY_LIST_BAD_LIMIT limit 必须是整数,收到「{rest_args[i + 1]}」。")
|
||||
return None, None, 1
|
||||
i += 2
|
||||
else:
|
||||
if platform_filter is None:
|
||||
platform_filter = rest_args[i]
|
||||
i += 1
|
||||
return platform_filter, limit, 0
|
||||
|
||||
def _parse_add_args(rest_args):
|
||||
if len(rest_args) < 1:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
print("用法:python main.py add <平台> [API_Key] [--model 模型] [--label 备注]")
|
||||
return None, None, None, None, 1
|
||||
platform_arg = rest_args[0]
|
||||
api_key_arg = ""
|
||||
model_arg = None
|
||||
label_arg = ""
|
||||
i = 1
|
||||
if i < len(rest_args) and not rest_args[i].startswith("--"):
|
||||
api_key_arg = rest_args[i]
|
||||
i += 1
|
||||
while i < len(rest_args):
|
||||
if rest_args[i] == "--model" and i + 1 < len(rest_args):
|
||||
model_arg = rest_args[i + 1]
|
||||
i += 2
|
||||
elif rest_args[i] == "--label" and i + 1 < len(rest_args):
|
||||
label_arg = rest_args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
return platform_arg, api_key_arg, model_arg, label_arg, 0
|
||||
|
||||
cmd = args[0]
|
||||
|
||||
if cmd == "health":
|
||||
cmd_health()
|
||||
|
||||
elif cmd == "version":
|
||||
cmd_version()
|
||||
|
||||
elif cmd == "generate":
|
||||
if len(args) < 3:
|
||||
print("ERROR:CLI_GENERATE_MISSING_ARGS")
|
||||
print("用法:python main.py generate <平台名或account_id> \"<提示词>\"")
|
||||
return 1
|
||||
if not cmd_generate(args[1], args[2]):
|
||||
return 1
|
||||
|
||||
elif cmd == "list":
|
||||
platform_filter, limit, err = _parse_list_args(args[1:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_list(platform_filter, limit=limit)
|
||||
|
||||
elif cmd == "add":
|
||||
platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[1:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg)
|
||||
|
||||
elif cmd == "del":
|
||||
if len(args) < 2:
|
||||
print("ERROR:CLI_DEL_MISSING_ARGS 用法:python main.py del <key_id>")
|
||||
return 1
|
||||
cmd_key_del(args[1])
|
||||
|
||||
elif cmd == "key":
|
||||
if len(args) < 2:
|
||||
print("ERROR:CLI_KEY_MISSING_SUBCOMMAND 请指定子命令:add / list / del")
|
||||
return 1
|
||||
sub = args[1]
|
||||
|
||||
if sub == "add":
|
||||
platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[2:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg)
|
||||
|
||||
elif sub == "list":
|
||||
platform_filter, limit, err = _parse_list_args(args[2:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_list(platform_filter, limit=limit)
|
||||
|
||||
elif sub == "del":
|
||||
if len(args) < 3:
|
||||
print("ERROR:CLI_KEY_DEL_MISSING_ARGS 用法:python main.py key del <key_id>")
|
||||
return 1
|
||||
cmd_key_del(args[2])
|
||||
|
||||
else:
|
||||
print(f"ERROR:CLI_UNKNOWN_KEY_SUB 未知 key 子命令「{sub}」,支持:add / list / del")
|
||||
return 1
|
||||
|
||||
else:
|
||||
print(f"ERROR:CLI_UNKNOWN_COMMAND 未知命令「{cmd}」。")
|
||||
_print_usage()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
199
llm-manager/scripts/providers.py
Normal file
199
llm-manager/scripts/providers.py
Normal 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-manager:pick-logged-in <platform_key>,取该平台已登录(login_status=1)的优先账号。
|
||||
成功返回与 account.py get 一致的 dict;失败或不可用时返回 None。
|
||||
"""
|
||||
script = _account_manager_script_path()
|
||||
if not os.path.isfile(script):
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, script, "pick-logged-in", provider_key],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
raw = (proc.stdout or "").strip()
|
||||
if not raw or raw.startswith("ERROR:"):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw.splitlines()[0])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("id") is None:
|
||||
return None
|
||||
plat = data.get("platform") or provider_key
|
||||
if not (data.get("url") or "").strip():
|
||||
data["url"] = LLM_PROVIDERS.get(provider_key, {}).get("web_url", "")
|
||||
data["platform"] = plat
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chrome/Edge 检测(与 account-manager 逻辑保持一致)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _win_find_exe(candidates):
|
||||
for p in candidates:
|
||||
if p and os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def resolve_chromium_channel() -> str | None:
|
||||
"""返回 'chrome' | 'msedge' | None。"""
|
||||
if sys.platform != "win32":
|
||||
return "chrome"
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
local = os.environ.get("LocalAppData", "")
|
||||
|
||||
chrome = _win_find_exe([
|
||||
os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
|
||||
])
|
||||
if chrome:
|
||||
return "chrome"
|
||||
|
||||
edge = _win_find_exe([
|
||||
os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "",
|
||||
])
|
||||
if edge:
|
||||
return "msedge"
|
||||
|
||||
return None
|
||||
11
logistics-tracker/.github/workflows/release_skill.yaml
vendored
Normal file
11
logistics-tracker/.github/workflows/release_skill.yaml
vendored
Normal 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
logistics-tracker/SKILL.md
Normal file
53
logistics-tracker/SKILL.md
Normal 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次查询,正式使用需升级
|
||||
220
logistics-tracker/release.ps1
Normal file
220
logistics-tracker/release.ps1
Normal 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
|
||||
}
|
||||
142
logistics-tracker/scripts/main.py
Normal file
142
logistics-tracker/scripts/main.py
Normal 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())
|
||||
@@ -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` | 常见技能形态与自检清单 |
|
||||
|
||||
59
skill-template/docs/LOGGING.md
Normal file
59
skill-template/docs/LOGGING.md
Normal 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` 即可。
|
||||
23
skill-template/release.ps1
Normal file
23
skill-template/release.ps1
Normal 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
|
||||
11
sohu-publisher/.github/workflows/release_skill.yaml
vendored
Normal file
11
sohu-publisher/.github/workflows/release_skill.yaml
vendored
Normal 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
77
sohu-publisher/SKILL.md
Normal 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
220
sohu-publisher/release.ps1
Normal 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
|
||||
}
|
||||
563
sohu-publisher/scripts/main.py
Normal file
563
sohu-publisher/scripts/main.py
Normal 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())
|
||||
13
toutiao-publisher/.github/workflows/release_skill.yaml
vendored
Normal file
13
toutiao-publisher/.github/workflows/release_skill.yaml
vendored
Normal 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
8
toutiao-publisher/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
40
toutiao-publisher/README.md
Normal file
40
toutiao-publisher/README.md
Normal 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 且需同步
|
||||
```
|
||||
52
toutiao-publisher/SKILL.md
Normal file
52
toutiao-publisher/SKILL.md
Normal 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/` 下;不得将用户数据提交到版本库。
|
||||
56
toutiao-publisher/docs/PORTABILITY.md
Normal file
56
toutiao-publisher/docs/PORTABILITY.md
Normal 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)
|
||||
55
toutiao-publisher/docs/RUNTIME.md
Normal file
55
toutiao-publisher/docs/RUNTIME.md
Normal 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` 注释示例)。**不要在业务模块中散落多套变量名判断。**
|
||||
33
toutiao-publisher/docs/SKILL_TYPES.md
Normal file
33
toutiao-publisher/docs/SKILL_TYPES.md
Normal 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` 时行为已文档化
|
||||
- [ ] 不向仓库提交用户数据、密钥、大型二进制
|
||||
- [ ] 错误信息包含「如何修复」(缺什么环境变量、缺哪个依赖)
|
||||
17
toutiao-publisher/optional/README.md
Normal file
17
toutiao-publisher/optional/README.md
Normal 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 与表名。
|
||||
92
toutiao-publisher/optional/paths_snippet.py
Normal file
92
toutiao-publisher/optional/paths_snippet.py
Normal 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)
|
||||
48
toutiao-publisher/optional/sqlite_minimal.py
Normal file
48
toutiao-publisher/optional/sqlite_minimal.py
Normal 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()
|
||||
23
toutiao-publisher/release.ps1
Normal file
23
toutiao-publisher/release.ps1
Normal 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
|
||||
83
toutiao-publisher/scripts/skill_main.py
Normal file
83
toutiao-publisher/scripts/skill_main.py
Normal 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())
|
||||
Reference in New Issue
Block a user