Add OpenClaw skills, platform kit, and template docs

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

View File

@@ -0,0 +1,11 @@
name: 技能自动化发布
on:
push:
tags: ["v*"]
jobs:
release:
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
with:
artifact_platform: windows
pyarmor_platform: windows.x86_64

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

@@ -0,0 +1,77 @@
---
name: 搜狐号自动发布
description: 搜狐号自动发布大总管。系统会自动帮你查找文章ID和账号ID严禁向用户询问代码层的参数
version: 1.1.0
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: sohu-publisher
emoji: "📰"
category: "通用"
dependencies:
required:
- content-manager
- account-manager
auto_install: true
allowed-tools:
- bash
---
# 搜狐号全自动发布指引
## 🚨 AI 必备纪律 (严禁直接询问用户参数 ❗)
你是一个面向小白用户的顶级全自动运营总监。用户**不懂代码**,他们不知道什么是 `article_id=3` ,也不懂什么是 `account_id=sohu_account1`
用户只会用自己的视角跟你下达命令(比如:“帮我把最新的一篇发到搜狐号”、“用 13800000000 那个账号发一下文章”)。
当你收到这类含有发布意图的指令时,**绝对不能打断用户,要求他们提供 account_id 和 article_id**。你需要按照以下【静默 3 步走】机制,替用户去把需要的值找出来!
### 🎯 步骤 1自动感知识别【文章数字 ID】
如果你不知道确切的文章ID数字是多少比如用户只说了发下最新的或者发刚写好的
1. 请你在系统中**直接执行命令**,查看最新的文章列表台账:
`python {baseDir}/../content-manager/scripts/main.py list`
2. 阅读返回的表格。通过列表最顶部的记录,或者是标题匹配您的上下文,**你自己**把那篇目标文章的最左侧**数字 ID**挖出来。千万别去问用户。
### 🎯 步骤 2自动感知识别【账号 ID】
用户通常用**手机号**区分账号。如果你不知道该用哪个发:
1. 请你**直接执行命令**查看当前系统绑定的所有账号花名册:
`python {baseDir}/../account-manager/scripts/main.py list`
2. 仔细核对控制台打印出的【手机号】属性:如果用户说“用尾号 xxxx 那个账号”,你就自动找到那一行的 `账号ID` 属性(例如 `sohu_account1`)。
3. **特例**:如果你发现其实系统里只绑定了 1 个搜狐账号,你连查都不用查,直接默认提取 `sohu_account1` 充当参数。绝对不要拿这种事麻烦用户!
### 🎯 步骤 3直接一键开火发布
当你自己在后台摸清楚了 `account_id`(字符串)和 `article_id`(数字) 后,请热情地对用户说:“好的老大,这就开始帮您操作发布...”,然后立刻替用户在终端执以下命令发起物理发布引擎:
```bash
python {baseDir}/scripts/main.py <account_id> <article_id>
```
**示例**
`python {baseDir}/scripts/main.py sohu_account1 3`
## 常用 CLI给小白也能看懂
```bash
# 发布一篇(推荐显式写 publish
python {baseDir}/scripts/main.py publish <account_id> <article_id>
# 兼容旧写法(等价于 publish
python {baseDir}/scripts/main.py <account_id> <article_id>
# 查看发布记录(默认最新 10 条,按创建时间倒序)
python {baseDir}/scripts/main.py logs
python {baseDir}/scripts/main.py logs --limit 20
python {baseDir}/scripts/main.py logs --status failed
python {baseDir}/scripts/main.py logs --account-id sohu_account1
# 查看某一条发布记录JSON
python {baseDir}/scripts/main.py log-get <log_id>
# 健康/版本
python {baseDir}/scripts/main.py health
python {baseDir}/scripts/main.py version
```
---
> 💡 若你执行发布脚本后,控制台向你抛出了 `ERROR:REQUIRE_LOGIN` 警告和一系列提示。这说明你成功启动了代理人流程,但搜狐账号已经掉线了!这时脚本实际上已经替您调起了浏览器的登录弹窗。
> 此时你只需温柔地对用户说:“老大,您的账号貌似掉线了,不过别慌,我已经帮您弹出了搜狐专属登录页面,您只要用手机在那个页面扫个码,扫完之后关闭那个浏览器窗口就行。等您弄好了告诉我,我再帮您从头发布一次!”

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

@@ -0,0 +1,220 @@
<#
.SYNOPSIS
One-command release script for skill repos.
.DESCRIPTION
- Optional auto-commit
- Push current branch
- Auto-increment semantic tag (vX.Y.Z)
- Create & push tag
- Fail fast on unsafe states
.EXAMPLES
# Safe mode (recommended): requires clean working tree
.\release.ps1
# Auto commit tracked/untracked changes then release
.\release.ps1 -AutoCommit -CommitMessage "chore: update skill config"
# Dry run (show what would happen)
.\release.ps1 -DryRun
# Custom tag prefix
.\release.ps1 -Prefix "v" -Message "正式发布"
.NOTES
Requires: git, PowerShell 5+
#>
[CmdletBinding()]
param(
[string]$Prefix = "v",
[string]$Message = "正式发布",
[switch]$AutoCommit,
[switch]$RequireClean,
[string]$CommitMessage,
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1"
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
if (Test-Path $sharedScript) {
& $sharedScript @PSBoundParameters
exit $LASTEXITCODE
}
function Invoke-Git {
param([Parameter(Mandatory = $true)][string]$Args)
Write-Host ">> git $Args" -ForegroundColor DarkGray
& cmd /c "git $Args"
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
}
function Get-GitOutput {
param([Parameter(Mandatory = $true)][string]$Args)
$output = & cmd /c "git $Args" 2>$null
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
return @($output)
}
function Test-Repo {
& git rev-parse --is-inside-work-tree *> $null
return ($LASTEXITCODE -eq 0)
}
function Get-CurrentBranch {
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
return $b
}
function Get-StatusPorcelain {
$lines = @(Get-GitOutput "status --porcelain")
return $lines
}
function Parse-SemVerTag {
param(
[string]$Tag,
[string]$TagPrefix
)
$escaped = [regex]::Escape($TagPrefix)
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
if (-not $m.Success) { return $null }
return [pscustomobject]@{
Raw = $Tag
Major = [int]$m.Groups[1].Value
Minor = [int]$m.Groups[2].Value
Patch = [int]$m.Groups[3].Value
}
}
function Get-NextTag {
param([string]$TagPrefix)
$tags = Get-GitOutput "tag --list"
$parsed = @()
foreach ($t in $tags) {
$t = $t.Trim()
if (-not $t) { continue }
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
if ($null -ne $obj) { $parsed += $obj }
}
if ($parsed.Count -eq 0) {
return "${TagPrefix}1.0.1"
}
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
}
function Ensure-CleanOrAutoCommit {
param(
[switch]$DoAutoCommit,
[switch]$NeedClean,
[switch]$IsDryRun,
[string]$Msg
)
$status = @(Get-StatusPorcelain)
if ($status.Length -eq 0) { return }
if ($NeedClean) {
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
& git status --short
throw "Abort: dirty working tree."
}
# 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启
$commitMsg = $Msg
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
}
if (-not $DoAutoCommit) {
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
}
if ($IsDryRun) {
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
return
}
Invoke-Git "add -A"
Invoke-Git "commit -m `"$commitMsg`""
}
try {
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
if (-not (Test-Repo)) {
throw "Current directory is not a git repository."
}
$branch = Get-CurrentBranch
if ([string]::IsNullOrWhiteSpace($branch)) {
throw "Unable to determine current branch."
}
if ($branch -notin @("main", "master")) {
throw "Current branch is '$branch'. Release is only allowed from main/master."
}
Invoke-Git "fetch --tags --prune origin"
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null)
$hasUpstream = ($LASTEXITCODE -eq 0)
if ($DryRun) {
if ($hasUpstream) {
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
} else {
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
}
} else {
if ($hasUpstream) {
Invoke-Git "push"
} else {
Invoke-Git "push -u origin $branch"
}
}
$nextTag = Get-NextTag -TagPrefix $Prefix
Write-Host "Next tag: $nextTag" -ForegroundColor Green
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
if ($existing.Length -gt 0) {
throw "Tag already exists: $nextTag"
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
exit 0
}
Invoke-Git "tag -a $nextTag -m `"$Message`""
Invoke-Git "push origin $nextTag"
Write-Host "Release success: $nextTag" -ForegroundColor Green
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
exit 0
}
catch {
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View File

@@ -0,0 +1,563 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sohu-publisher搜狐号自动发布。
子命令:
publish <account_id> <article_id> 发布文章
logs [--limit N] [--status s] [--account-id a] 查看发布记录
log-get <log_id> 查看单条发布记录(JSON)
health | version
"""
from __future__ import annotations
import argparse
import asyncio
import io
import json
import os
import sqlite3
import subprocess
import sys
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
import requests
from playwright.async_api import async_playwright
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
SKILL_SLUG = "sohu-publisher"
SKILL_VERSION = "1.2.0"
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OPENCLAW_DIR = os.path.dirname(BASE_DIR)
def _now_unix() -> int:
return int(time.time())
def _unix_to_iso(ts: Optional[int]) -> Optional[str]:
if ts is None:
return None
try:
return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
except (ValueError, OSError, OverflowError):
return None
def get_data_root() -> str:
env = (os.getenv("CLAW_DATA_ROOT") or os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
if env:
return env
if sys.platform == "win32":
return r"D:\claw-data"
return os.path.join(os.path.expanduser("~"), ".claw-data")
def get_user_id() -> str:
return (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
def get_skills_root() -> str:
env = (os.getenv("CLAW_SKILLS_ROOT") or os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
if env:
return env
return OPENCLAW_DIR
def get_skill_data_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path() -> str:
return os.path.join(get_skill_data_dir(), "sohu-publisher.db")
def get_conn() -> sqlite3.Connection:
return sqlite3.connect(get_db_path())
def init_db() -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS publish_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL,
article_id INTEGER NOT NULL,
article_title TEXT,
status TEXT NOT NULL,
error_msg TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
cur.execute("PRAGMA table_info(publish_logs)")
cols = {r[1] for r in cur.fetchall()}
if "article_title" not in cols:
cur.execute("ALTER TABLE publish_logs ADD COLUMN article_title TEXT")
if "updated_at" not in cols:
cur.execute("ALTER TABLE publish_logs ADD COLUMN updated_at INTEGER")
if "created_at" not in cols:
cur.execute("ALTER TABLE publish_logs ADD COLUMN created_at INTEGER")
conn.commit()
finally:
conn.close()
def save_publish_log(account_id: str, article_id: int, article_title: str, status: str, error_msg: Optional[str] = None) -> int:
init_db()
now = _now_unix()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO publish_logs (account_id, article_id, article_title, status, error_msg, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(account_id, int(article_id), article_title or "", status, error_msg, now, now),
)
new_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
return new_id
def check_entitlement(skill_slug: str) -> tuple[bool, str]:
auth_base = (os.getenv("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/")
if not auth_base:
return True, ""
user_id = (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
if not user_id:
return False, "鉴权失败缺少用户身份CLAW_USER_ID / JIANGCHANG_USER_ID"
auth_api_key = (os.getenv("JIANGCHANG_AUTH_API_KEY") or "").strip()
timeout = int((os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip())
headers = {"Content-Type": "application/json"}
if auth_api_key:
headers["Authorization"] = f"Bearer {auth_api_key}"
payload = {
"user_id": user_id,
"skill_slug": skill_slug,
"trace_id": (os.getenv("JIANGCHANG_TRACE_ID") or "").strip(),
"context": {"entry": "main.py"},
}
try:
res = requests.post(f"{auth_base}/api/entitlements/check", json=payload, headers=headers, timeout=timeout)
except requests.RequestException as exc:
return False, f"鉴权请求失败:{exc}"
if res.status_code != 200:
return False, f"鉴权服务异常HTTP {res.status_code}"
try:
body = res.json()
except ValueError:
return False, "鉴权服务异常:返回非 JSON"
code = body.get("code")
data = body.get("data") or {}
if code != 200:
return False, str(body.get("msg") or "鉴权失败")
if not data.get("allow", False):
return False, str(data.get("reason") or "未购买或已过期")
return True, ""
def _call_json_script(script_path: str, args: List[str]) -> Optional[Dict[str, Any]]:
proc = subprocess.run([sys.executable, script_path, *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
raw = (proc.stdout or "").strip()
if not raw or raw.startswith("ERROR"):
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
def _normalize_sohu_title(raw_title: str, article: Dict[str, Any]) -> str:
"""
搜狐标题约束5-72 字。
- >72截断
- <5用正文首行/前缀补足
"""
title = (raw_title or "").strip()
if len(title) > 72:
title = title[:72].rstrip()
if len(title) >= 5:
return title
body = str(article.get("content") or article.get("content_html") or "").strip()
first_line = ""
for line in body.splitlines():
t = line.strip()
if t:
first_line = t
break
seed = first_line or "搜狐发布稿件"
seed = seed.replace("\r", " ").replace("\n", " ").strip()
# 先把 seed 拼上,仍不足时再补固定后缀,最终保证 >=5
merged = (title + seed).strip()
if len(merged) < 5:
merged = (merged + "发布稿件标题").strip()
if len(merged) > 72:
merged = merged[:72].rstrip()
return merged
def get_account(account_id: str) -> Optional[Dict[str, Any]]:
script = os.path.join(get_skills_root(), "account-manager", "scripts", "main.py")
return _call_json_script(script, ["get", str(account_id)])
def get_article(article_id: str) -> Optional[Dict[str, Any]]:
script = os.path.join(get_skills_root(), "content-manager", "scripts", "main.py")
return _call_json_script(script, ["get", str(article_id)])
async def publish(account: Dict[str, Any], article: Dict[str, Any], account_id: str) -> str:
profile_dir = account["profile_dir"]
original_title = str(article.get("title") or "")
title = _normalize_sohu_title(original_title, article)
if title != original_title:
print(f" 标题已自动修正(搜狐要求 5-72 字):\n 原标题:{original_title}\n 新标题:{title}")
content_html = article.get("content_html", article.get("content", ""))
async with async_playwright() as p:
browser = await p.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=False,
channel="chrome",
no_viewport=True,
permissions=["clipboard-read", "clipboard-write"],
args=["--start-maximized"],
)
page = browser.pages[0] if browser.pages else await browser.new_page()
await page.goto("https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?contentStatus=1")
await page.wait_for_load_state("networkidle")
try:
title_input = page.locator(".publish-title input").first
await title_input.wait_for(state="visible", timeout=10000)
except Exception:
await browser.close()
return "ERROR:REQUIRE_LOGIN"
print("💡 页面加载且已确认登录,开始自动填入文字...")
await title_input.click()
await title_input.fill("")
await page.keyboard.insert_text(title)
await asyncio.sleep(1)
editor = page.locator("#editor .ql-editor").first
await editor.click()
await page.evaluate(
"""(html_str) => {
const blobHtml = new Blob([html_str], { type: 'text/html' });
const blobText = new Blob([html_str], { type: 'text/plain' });
const item = new window.ClipboardItem({
'text/html': blobHtml,
'text/plain': blobText
});
return navigator.clipboard.write([item]);
}""",
content_html,
)
modifier = "Meta" if sys.platform == "darwin" else "Control"
await page.keyboard.press(f"{modifier}+v")
await asyncio.sleep(3)
await page.locator("li.publish-report-btn").first.click()
print("⌛ 正在提交发布,进入高压视觉核验阶段...")
publish_success = False
error_text = "动作执行阻断:由于特殊元素拦截、频率限制或底层报错,未能成功发出。"
try:
async with page.expect_navigation(url=lambda u: "addarticle" not in u, timeout=8000):
pass
publish_success = True
except Exception:
try:
limit_text = page.locator("text=/.*已达上限.*/").first
if await limit_text.is_visible(timeout=1500):
error_text = await limit_text.inner_text()
else:
error_msg = await page.evaluate(
"""() => {
const els = Array.from(document.querySelectorAll('div, span, p'));
for (let el of els) {
const style = window.getComputedStyle(el);
if ((style.position === 'fixed' || style.position === 'absolute')
&& parseInt(style.zIndex || 0) > 80
&& el.innerText.trim().length > 3
&& el.innerText.trim().length < 80) {
return el.innerText.trim();
}
}
return null;
}"""
)
if error_msg:
error_text = f"抓取到报错原文: {error_msg}"
except Exception as e:
error_text = f"抓取报错文案期间遭遇环境隔离: {e}"
await asyncio.sleep(5)
await browser.close()
if publish_success:
return "SUCCESS"
return f"FAIL:{error_text}"
def cmd_publish(account_id: str, article_id: str) -> int:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
print(f"{reason}")
return 1
if not str(article_id).isdigit():
print("❌ article_id 必须是数字。请先执行 content-manager 的 list 查看 id。")
return 1
account = get_account(account_id)
if not account:
print(f"❌ 查无此配置账号:{account_id}")
return 1
platform = str(account.get("platform") or "").strip().lower()
if platform != "sohu":
platform_cn_map = {
"doubao": "豆包",
"deepseek": "DeepSeek",
"qianwen": "通义千问",
"kimi": "Kimi",
"yiyan": "文心一言",
"yuanbao": "腾讯元宝",
"toutiao": "头条号",
"zhihu": "知乎",
"wechat": "微信公众号",
"sohu": "搜狐号",
}
got_cn = platform_cn_map.get(platform, platform or "未知平台")
print("❌ 账号平台不匹配:当前账号不是「搜狐号」。")
print(f"当前 account_id={account_id} 对应平台:{got_cn}platform={platform or 'unknown'}")
print("请换一个搜狐账号 id 后重试。")
print("可先执行python account-manager/scripts/main.py list sohu")
return 1
login_status = int(account.get("login_status") or 0)
if login_status != 1:
print("❌ 该搜狐账号当前未登录,暂不能发布。")
print("请先手工登录,再执行发布:")
print(f" python account-manager/scripts/main.py login {account_id}")
print(f"登录完成后再执行python sohu-publisher/scripts/main.py publish {account_id} {article_id}")
return 1
article = get_article(article_id)
if not article:
print(f"❌ 查无此文章编号(库中无 ID: {article_id}")
return 1
result = asyncio.run(publish(account, article, account_id))
content_script = os.path.join(get_skills_root(), "content-manager", "scripts", "main.py")
title = article.get("title", "")
if result == "ERROR:REQUIRE_LOGIN":
save_publish_log(account_id, int(article_id), title, "require_login", "账号未登录或登录已失效")
print(f"⚠️ 搜狐号 ({account_id}) 登录状态已失效,发布流程已中止。")
print("请先手工完成登录,再重新发布:")
print(f" python account-manager/scripts/main.py login {account_id}")
print(f" python sohu-publisher/scripts/main.py publish {account_id} {article_id}")
return 1
if result == "SUCCESS":
log_id = save_publish_log(account_id, int(article_id), title, "published", None)
subprocess.run([sys.executable, content_script, "feedback", article_id, "published", account_id])
print(f"🎉 发布成功:{title}")
print(f"✅ 发布日志已记录log_id={log_id}")
return 0
if result.startswith("FAIL:"):
error_msg = result[len("FAIL:") :]
log_id = save_publish_log(account_id, int(article_id), title, "failed", error_msg)
subprocess.run([sys.executable, content_script, "feedback", article_id, "failed", account_id, error_msg])
print(f"❌ 发布失败:{error_msg}")
print(f"✅ 失败日志已记录log_id={log_id}")
return 1
save_publish_log(account_id, int(article_id), title, "failed", f"未知结果:{result}")
return 1
def cmd_logs(limit: int = 10, status: Optional[str] = None, account_id: Optional[str] = None) -> int:
init_db()
if limit <= 0:
limit = 10
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at "
"FROM publish_logs WHERE 1=1 "
)
params: List[Any] = []
if status:
sql += "AND status = ? "
params.append(status)
if account_id:
sql += "AND account_id = ? "
params.append(account_id)
sql += "ORDER BY created_at DESC, id DESC LIMIT ?"
params.append(int(limit))
cur.execute(sql, tuple(params))
rows = cur.fetchall()
finally:
conn.close()
if not rows:
print("暂无发布记录")
return 0
sep_line = "_" * 39
for idx, r in enumerate(rows):
rid, aid, arid, title, st, err, cat, uat = r
print(f"id{rid}")
print(f"account_id{aid or ''}")
print(f"article_id{arid}")
print(f"article_title{title or ''}")
print(f"status{st or ''}")
print(f"error_msg{err or ''}")
print(f"created_at{_unix_to_iso(cat) or str(cat or '')}")
print(f"updated_at{_unix_to_iso(uat) or str(uat or '')}")
if idx != len(rows) - 1:
print(sep_line)
print()
return 0
def cmd_log_get(log_id: str) -> int:
if not str(log_id).isdigit():
print("❌ log_id 必须是数字")
return 1
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at FROM publish_logs WHERE id = ?",
(int(log_id),),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
print("❌ 没有这条发布记录")
return 1
rid, aid, arid, title, st, err, cat, uat = row
print(
json.dumps(
{
"id": int(rid),
"account_id": aid,
"article_id": int(arid),
"article_title": title,
"status": st,
"error_msg": err,
"created_at": _unix_to_iso(cat),
"updated_at": _unix_to_iso(uat),
},
ensure_ascii=False,
)
)
return 0
class ZhArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> None:
print(f"参数错误:{message}\n请执行python main.py -h 查看帮助", file=sys.stderr)
self.exit(2)
def _print_full_usage() -> None:
print("搜狐号发布main.py可用命令")
print(" python main.py publish <account_id> <article_id> # 发布一篇")
print(" python main.py logs [--limit N] [--status s] [--account-id a] # 查看发布记录")
print(" python main.py log-get <log_id> # 查看单条日志(JSON)")
print(" python main.py health")
print(" python main.py version")
print()
print("常见示例:")
print(" python main.py publish sohu_account1 12")
print(" python main.py logs")
print(" python main.py logs --status failed --limit 20")
print(" python main.py log-get 7")
print()
print("说明也兼容旧写法python main.py <account_id> <article_id>")
def build_parser() -> ZhArgumentParser:
p = ZhArgumentParser(
prog="main.py",
description="搜狐号发布:发布文章、查看发布记录、查询单条日志。",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python main.py publish sohu_account1 12\n"
" python main.py logs\n"
" python main.py logs --status failed --limit 20\n"
" python main.py log-get 7\n"
" python main.py health\n"
" python main.py version"
),
)
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
sp = sub.add_parser("publish", help="发布一篇文章到搜狐号")
sp.add_argument("account_id", help="账号 id来自 account-manager list")
sp.add_argument("article_id", help="文章 id来自 content-manager list")
sp.set_defaults(handler=lambda a: cmd_publish(a.account_id, a.article_id))
sp = sub.add_parser("logs", help="查看发布记录(默认最近 10 条)")
sp.add_argument("--limit", type=int, default=10, help="最多显示条数(默认 10")
sp.add_argument("--status", default=None, help="按状态筛选published/failed/require_login")
sp.add_argument("--account-id", default=None, help="按账号 id 筛选")
sp.set_defaults(handler=lambda a: cmd_logs(limit=a.limit, status=a.status, account_id=a.account_id))
sp = sub.add_parser("log-get", help="按 log_id 查看单条发布记录(JSON)")
sp.add_argument("log_id", help="日志 id整数")
sp.set_defaults(handler=lambda a: cmd_log_get(a.log_id))
sp = sub.add_parser("health", help="健康检查")
sp.set_defaults(handler=lambda _a: 0 if sys.version_info >= (3, 10) else 1)
sp = sub.add_parser("version", help="版本信息(JSON)")
sp.set_defaults(
handler=lambda _a: (
print(json.dumps({"version": SKILL_VERSION, "skill": SKILL_SLUG}, ensure_ascii=False)) or 0
)
)
return p
def main(argv: Optional[List[str]] = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
if not argv:
_print_full_usage()
return 1
# 兼容旧用法python main.py <account_id> <article_id>
if len(argv) == 2 and argv[0] not in {"publish", "logs", "log-get", "health", "version", "-h", "--help"}:
return cmd_publish(argv[0], argv[1])
parser = build_parser()
args = parser.parse_args(argv)
return int(args.handler(args))
if __name__ == "__main__":
raise SystemExit(main())