Add OpenClaw skills, platform kit, and template docs
Made-with: Cursor
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user