From 9f7eec8bfc1915fbb19a0df1e1f53f8da3ae8b30 Mon Sep 17 00:00:00 2001 From: bigbrother666sh Date: Fri, 26 Jun 2026 00:10:29 +0800 Subject: [PATCH 1/2] 5.5.2 release --- CHANGELOG.md | 58 ++ addons/README.md | 2 +- .../crew/business-developer/ALLOWED_COMMANDS | 12 + addons/officials/crew/designer/USER.md | 2 +- addons/officials/crew/ir/ALLOWED_COMMANDS | 12 + addons/officials/crew/ir/DENIED_SKILLS | 1 - .../officials/crew/sales-cs/ALLOWED_COMMANDS | 8 +- .../crew/sales-cs/skills/exp_invite/SKILL.md | 35 +- .../skills/exp_invite/scripts/invite.sh | 31 +- .../crew/selfmedia-operator/AGENTS.md | 99 ++- .../crew/selfmedia-operator/ALLOWED_COMMANDS | 38 ++ .../crew/selfmedia-operator/BUILTIN_SKILLS | 8 + .../crew/selfmedia-operator/HEARTBEAT.md | 221 +++++- .../crew/selfmedia-operator/IDENTITY.md | 2 +- .../officials/crew/selfmedia-operator/SOUL.md | 2 - .../crew/selfmedia-operator/TOOLS.md | 22 + .../openclaw_setting_sample.json | 5 + .../skills/douyin-publish/SKILL.md | 95 +-- .../douyin-publish/scripts/publish_douyin.py | 388 +++++++---- .../skills/facebook-publish/SKILL.md | 21 - .../skills/highlight-clipper/SKILL.md | 10 +- .../skills/highlight-clipper/scripts/clip.py | 75 ++- .../skills/instagram-publish/SKILL.md | 21 - .../skills/juejin-publish/SKILL.md | 27 +- .../skills/pinterest-publish/SKILL.md | 21 - .../skills/published-track/SKILL.md | 192 ++++-- .../scripts/check-published.sh | 16 +- .../skills/published-track/scripts/init-db.sh | 233 ++++++- .../skills/published-track/scripts/query.sh | 16 +- .../skills/published-track/scripts/record.sh | 189 +++++- .../published-track/scripts/update-metrics.sh | 83 ++- .../skills/t2video/SKILL.md | 215 ------ .../skills/t2video/scripts/assemble.py | 269 -------- .../skills/t2video/scripts/check.py | 472 ------------- .../skills/t2video/scripts/gen.py | 165 ----- .../skills/t2video/scripts/tts.py | 457 ------------- .../skills/threads-publish/SKILL.md | 21 - .../skills/tiktok-publish/SKILL.md | 21 - .../skills/toutiao-publish/SKILL.md | 27 +- .../skills/twitter-post/SKILL.md | 27 +- .../skills/wx-mp-publisher/SKILL.md | 22 +- .../skills/xhs-content-ops/SKILL.md | 231 ++++--- .../skills/youtube-publish/SKILL.md | 21 - addons/officials/requirements.txt | 3 +- addons/officials/skills/email-ops/SKILL.md | 2 + .../officials/skills/pexels-footage/SKILL.md | 2 + addons/officials/skills/pitch-deck/SKILL.md | 88 ++- .../officials/skills/pixabay-footage/SKILL.md | 2 + addons/officials/skills/ppt-maker/SKILL.md | 13 + .../skills/ppt-maker/scripts/generate_pptx.py | 15 +- addons/officials/skills/rss-reader/SKILL.md | 2 + .../skills/siliconflow-img-gen/SKILL.md | 68 ++ .../skills/siliconflow-img-gen/scripts/gen.py | 9 + .../officials/skills/web-form-fill/SKILL.md | 281 ++------ addons/officials/skills/xhs-interact/SKILL.md | 57 +- awada/awada-extension/src/message-handler.ts | 9 +- awada/awada-extension/src/reply-dispatcher.ts | 8 +- .../src/infrastructure/redis/consumer.ts | 93 +++ config-templates/openclaw-aihubmix.json | 3 - config-templates/openclaw.json | 95 ++- crews/hrbp/USER.md | 4 + crews/it-engineer/MEMORY.md | 82 +++ crews/it-engineer/TOOLS.md | 2 +- crews/main/ALLOWED_COMMANDS | 21 +- .../reminder/scripts/update-reminders.py | 5 +- openclaw-weixin.version.json | 4 +- openclaw.version | 4 +- patches/005-browser-timeout-env-var.patch | 21 +- patches/README.md | 11 +- patches/overrides.sh | 42 +- scripts/install.sh | 27 +- scripts/lib/agent-skills.sh | 17 +- scripts/lib/exec-tiers.sh | 10 +- scripts/setup-crew.sh | 24 +- skills/smart-search/SKILL.md | 631 ++++-------------- 75 files changed, 2502 insertions(+), 3046 deletions(-) delete mode 100644 addons/officials/crew/selfmedia-operator/skills/t2video/SKILL.md delete mode 100644 addons/officials/crew/selfmedia-operator/skills/t2video/scripts/assemble.py delete mode 100644 addons/officials/crew/selfmedia-operator/skills/t2video/scripts/check.py delete mode 100644 addons/officials/crew/selfmedia-operator/skills/t2video/scripts/gen.py delete mode 100644 addons/officials/crew/selfmedia-operator/skills/t2video/scripts/tts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 99947cd7..58ccf511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,61 @@ +# v5.5.2 + +### Selfmedia Operator 视频制作与分发能力 + +- **一站式短视频制作**:`video-product` 技能支持文章链接、追爆报告、文字主题、本地文件等多种输入,自动生成脚本 → 逐段生成视频素材(声画同出)→ FFmpeg 合成成片。直连火山引擎 Seedance(doubao-seedance-2.0 系列)与阿里云百炼 Wan2.7-HappyHorse(happyhorse-1.1 系列)端点,按平台自动 fallback +- **视频分发**:新增微信视频号发布(`wechat-channels-publish`,处理 wujie shadow DOM),结合既有的小红书、抖音、Twitter/X、B站、快手等平台,实现短视频制作 → 多平台分发的闭环 +- **两个剪辑辅助技能**: + - `de-mouth`:口播视频去口误,自动识别并删除静音、语气词、卡顿词、重复句、残句,输出干净视频 + 字幕 + 剪映草稿 + - `highlight-clipper`:从本地视频中通过 ASR 转录 + 文本分析自动提取高光片段,剪辑输出多段短视频 +- Selfmedia Operator引入科学的评估方案和自动复盘方案(发布前预测打分 -> 每日数据复盘 -> 根据复盘调整打分量表 -> 不断优化预测准确性)。以上已内置到所有平台的发布流程中,让运营工作不再“凭感觉”。 + +### 主力模型切换为 GLM-5.2,推荐火山方舟 Coding Plan + +- `config-templates/openclaw.json` 主力模型由 DeepSeek V4 Pro 切换为 **GLM-5.2**(经火山引擎方舟 Coding Plan 接入,`awk/glm-latest`),fallback 为 siliconflow provider +- `install.sh` 交互式收集的 key 由 `DEEPSEEK_API_KEY` 改为 `AWK_API_KEY` +- 大模型推荐主推**火山方舟 Coding Plan**:支持 GLM-5.2、Kimi-K2.7、MiniMax-M3、DeepSeek-V4 系列、Doubao-Seed-2.0 系列等模型,工具不限;通过 wiseflow 邀请链接订阅叠加 9.5 折,首月尝鲜低至 9.4 元。邀请链接 https://volcengine.com/L/dx-wt80li-I/ ,邀请码 `5Y5A6L86` +- siliconflow、aihubmix 推荐不变(siliconflow 仍需申请,作为视觉/替补模型) + +> 想使用 5.5.2 的视频生成能力,需额外开通火山方舟 doubao-seedance-2.0 系列或阿里云百炼 happyhorse-1.1 系列模型,并将对应 key(`AWK_GEN_KEY` 或 `MODELSTUDIO_API_KEY`)配置到 `daemon.env`。 + +### openclaw 上游同步至 v2026.6.10 + +- 从 v2026.6.6 升级到 v2026.6.10 +- **删除 patch 001**(relax exec allowlist shell syntax):上游 exec 审批重构为 risk-based(`command-explainer` + `exec-authorization-plan`),`&&`/`||`/`;` 复合命令已原生逐段匹配 allowlist;`$()`/反引号/重定向上游仍拒但 wiseflow 已改走 `.sh` 脚本。原目标代码 `splitShellPipeline` 已删,无法 re-port +- **删除 patch 004**(chrome port grace retry):上游新增 `ensureManagedChromePortAvailable` + `recoverOwnedStaleManagedChromeCdpListener`,命中 EADDRINUSE 时主动杀掉占用端口的陈旧 Chrome 进程并清 singleton lock 再重探,比 3×500ms 轮询更强 +- 保留 patch 002/003/005/006(验证 apply 通过,上游无等价改动) + +### 上游关键变更摘要(与 wiseflow 相关) + +- **GLM-5.2(6.10)**:暴露 reasoning levels、GLM overload failover、Zai 合成模型回退 manifest baseUrl +- **心跳(6.9)**:修复 5.20 及所有 5.x 上心跳 scheduler 不触发的回归(#88970) +- **sessions_yield over MCP(6.9,#90861)**:修复 MCP 下 sessions_yield 保留 +- **安全(6.9)**:secrets redaction、阻断内部 HTTP session overrides、审计 open-DM tool exposure、plugin write owner check +- **存储(6.9)**:NFS 上禁用 SQLite WAL、reindex temp 清理、setup state 移出 workspace dot-dir +- **web search(6.9)**:Codex Hosted Search、key-free provider 保持 opt-in +- **6.10**:fast talks auto mode、channel switch reset 陈旧 origin 字段、hook registry 组合保留 trusted policies + +# v5.5.1 + +### openclaw 上游同步至 v2026.6.6 + +- 从 v2026.5.28 升级到 v2026.6.6(4253 commits,跨越 6.1→6.2→6.5→6.6 四个稳定版) +- 重新生成 patch 004(chrome-port-grace-retry)和 patch 005(browser-timeout-env-var)以适配上游文件重构 +- patch 001–003 验证通过,无需修改 + +### 上游关键变更摘要 + +- **安全加固**:exec 审批超时默认拒绝(fail-closed)、sandbox binds 收紧、MCP stdio 继承收紧、Codex HTTP 私有目标阻断、loopback tools 权限隔离 +- **OpenRouter 一等公民**:模型设置流程原生支持 OpenRouter OAuth/API-key +- **Parallel Search (Free)**:零配置内置 web search(无需 API key),作为 DuckDuckGo 之前的默认 fallback +- **移动端**:iPad 侧边栏 + iPhone Control Hub,Workboard/Skill Workshop 连接 Gateway +- **Telegram/iMessage**:account-scoped topic 路由、always-on inbound restart、durable echo markers +- **Browser/MCP**:existing-session CDP 支持、WebSocket validation、Streamable HTTP loopback、OAuth/SSE auth 修正 +- **Provider**:Claude Fable 5 adaptive thinking、Gemma 4 reasoning replay、本地模型跳过 guardian review、gpt-5.3-codex 恢复 +- **Cron**:wake 保留 originating session/agent、impossible cron 表达式拒绝创建 +- **启动提速**:cached model metadata、移除 startup catalog wait、lazy slash-command loading +- **QoL**:`openclaw update repair` 恢复路径、compaction timeout 默认降至 180s + # v5.5.0 ### 完全重新设计的部署与渠道绑定流程 diff --git a/addons/README.md b/addons/README.md index fc1e8177..a1e383c7 100644 --- a/addons/README.md +++ b/addons/README.md @@ -7,7 +7,7 @@ This directory's subdirectories are **git-ignored** — third-party addons are n wiseflow 采用两级扩展机制: -- **Base wiseflow**(`patches/` + `skills/`):每次 `apply-addons.sh` 运行时无条件应用,对所有 addon 和 crew 生效。包括代码补丁(`patches/*.patch`)和默认全局技能(`skills/`)。 +- **Base wiseflow**(`patches/` + `skills/`):每次 `apply-addons.sh` 运行时无条件应用,对所有 addon 和 crew 生效。包括代码补丁(`patches/*.patch`)、插件(`patches/suppress-stale-reply`)和默认全局技能(`skills/`)。 - **Addon**(`addons/*/`):在 base 之上叠加,提供额外全局技能(`skills/`)和 Crew 模板(`crew/`)。 > **注意**:addon 不包含 patches 层。如需对 openclaw 打补丁,请将 patch 放到项目根目录的 `patches/` 下,而非 addon 内部。 diff --git a/addons/officials/crew/business-developer/ALLOWED_COMMANDS b/addons/officials/crew/business-developer/ALLOWED_COMMANDS index 064cfcd2..da4106a6 100644 --- a/addons/officials/crew/business-developer/ALLOWED_COMMANDS +++ b/addons/officials/crew/business-developer/ALLOWED_COMMANDS @@ -8,3 +8,15 @@ +./skills/info-record/scripts/record-content.sh +./skills/info-record/scripts/query-today.sh +sqlite3 ++nano-pdf ++jq ++rg ++tmux ++curl ++summarize ++gifgrep ++node ++python3 ++/home/wukong/.openclaw/skills/wx-mp-hunter/scripts/wx-mp-hunter.sh ++/home/wukong/.openclaw/skills/login-manager/scripts/login-manager.sh ++/home/wukong/.openclaw/skills/login-manager/scripts/export-cookies.sh diff --git a/addons/officials/crew/designer/USER.md b/addons/officials/crew/designer/USER.md index 7ef044c8..64c5814e 100644 --- a/addons/officials/crew/designer/USER.md +++ b/addons/officials/crew/designer/USER.md @@ -11,4 +11,4 @@ The user is the boss. - 用户大多数时候有明确的功能需求,但视觉语言表达不精确 - 用户对品牌规范可能不熟悉,需要设计师主动查阅 MEMORY.md 并提醒约束 - 用户希望一次看到多个方案选择,而不是只看一个版本 -- 用户可能会用"再改一下"这样的模糊反馈,需要主动追问具体不满意点 \ No newline at end of file +- 用户可能会用"再改一下"这样的模糊反馈,需要主动追问具体不满意点 diff --git a/addons/officials/crew/ir/ALLOWED_COMMANDS b/addons/officials/crew/ir/ALLOWED_COMMANDS index 70c73802..5cf855d3 100644 --- a/addons/officials/crew/ir/ALLOWED_COMMANDS +++ b/addons/officials/crew/ir/ALLOWED_COMMANDS @@ -10,3 +10,15 @@ +./skills/ir-record/scripts/record-application.sh +./skills/ir-record/scripts/query-applications.sh +sqlite3 ++nano-pdf ++jq ++rg ++tmux ++curl ++summarize ++gifgrep ++node ++python3 ++/home/wukong/.openclaw/skills/wx-mp-hunter/scripts/wx-mp-hunter.sh ++/home/wukong/.openclaw/skills/login-manager/scripts/login-manager.sh ++/home/wukong/.openclaw/skills/login-manager/scripts/export-cookies.sh diff --git a/addons/officials/crew/ir/DENIED_SKILLS b/addons/officials/crew/ir/DENIED_SKILLS index 52d370bd..ef9bdbd4 100644 --- a/addons/officials/crew/ir/DENIED_SKILLS +++ b/addons/officials/crew/ir/DENIED_SKILLS @@ -1,4 +1,3 @@ -github gh-issues coding-agent diff --git a/addons/officials/crew/sales-cs/ALLOWED_COMMANDS b/addons/officials/crew/sales-cs/ALLOWED_COMMANDS index 35ea3ec7..b0026dd8 100644 --- a/addons/officials/crew/sales-cs/ALLOWED_COMMANDS +++ b/addons/officials/crew/sales-cs/ALLOWED_COMMANDS @@ -1,7 +1,6 @@ # customer-service ALLOWED_COMMANDS # 在 T0 基础上精确放行声明式技能所需脚本 # 格式:+ 追加允许(相对于 workspace 根目录) - # customer-db 具名操作脚本(无原子 SQL 访问权限) +./skills/customer-db/scripts/cs-update.sh +./skills/customer-db/scripts/follow-up-create.sh @@ -10,6 +9,11 @@ +./skills/customer-db/scripts/follow-up-mark-sent.sh +./skills/customer-db/scripts/follow-up-complete.sh +./skills/customer-db/scripts/follow-up-expire.sh - +./skills/exp_invite/scripts/invite.sh +./skills/proactive-send/scripts/send.sh ++nano-pdf ++./skills/payment_confirm/scripts/send-subs.sh ++./skills/payment_confirm/scripts/confirm-payment.sh ++./skills/payment_confirm/scripts/send-kb.sh ++jq ++rg \ No newline at end of file diff --git a/addons/officials/crew/sales-cs/skills/exp_invite/SKILL.md b/addons/officials/crew/sales-cs/skills/exp_invite/SKILL.md index c9ef0364..eb28cd8d 100644 --- a/addons/officials/crew/sales-cs/skills/exp_invite/SKILL.md +++ b/addons/officials/crew/sales-cs/skills/exp_invite/SKILL.md @@ -25,23 +25,40 @@ description: > - `--user-id-external`:来自消息上下文 Sender 块的 `id` 字段(awada 原始用户 ID),用于 awada 平台路由邀请动作 ## 行为规则 -- 邀请消息不是发给用户看的自然语言,而是 awada 控制消息: + +### 主动邀请限制 +对于 `business_status` 不为 `free` 的用户(如 `exp_invited`、`subs`、`club`): +- **不要主动邀请**他们进入体验群 +- 应回到主流程 3.7,继续主动引导 + +### 允许再次邀请的情况 +如果客户**明确要求**再次拉入群或再次 invite,可以使用 `--force` 参数强制发送邀请: +- 客户可能忘记接受之前的邀请 +- 客户可能主动退出后想重新加入 + +```bash +./skills/exp_invite/scripts/invite.sh \ + --peer "<[CustomerDB].peer>" \ + --user-id-external "" \ + --force +``` + +### business_status 更新规则 +- 若 `business_status` 为空或 `free`:更新为 `exp_invited` +- 若已有值(`exp_invited`/`subs`/`club`)且使用 `--force`:**不更新** business_status,仅发送邀请 + +### 邀请消息格式 +邀请消息是 awada 控制消息,不是自然语言: ```text /invite////风暴眼(wiseflow情报小站) ``` -- awada-channel 会将其转为拉群动作 -- 发送前先查询数据库: - - 若当前 `business_status` 已是 `exp_invited`,则**不要重复邀请** - - 此时应回到主流程 3.7,继续主动引导 -- 若尚未邀请,则: - 1. 更新数据库中的 `business_status = exp_invited` - 2. 输出 invite 控制消息 +awada-channel 会将其转为拉群动作。 ## 返回约定 - 成功:标准输出 invite 控制消息 -- 已邀请过:输出 `ALREADY_INVITED`,并以非 0 状态退出 +- 已邀请过且未指定 --force:输出 `ALREADY_INVITED`,并以非 0 状态退出 ## 当前体验群名称 - `风暴眼(wiseflow情报小站)` diff --git a/addons/officials/crew/sales-cs/skills/exp_invite/scripts/invite.sh b/addons/officials/crew/sales-cs/skills/exp_invite/scripts/invite.sh index 0c231784..c7ee8361 100755 --- a/addons/officials/crew/sales-cs/skills/exp_invite/scripts/invite.sh +++ b/addons/officials/crew/sales-cs/skills/exp_invite/scripts/invite.sh @@ -2,11 +2,15 @@ # Send awada invite control message and update customer status to exp_invited. # --peer: DB primary key (from [CustomerDB].peer), used for all DB operations. # --user-id-external: raw awada user ID (from Sender.id), used for the invite routing message. +# --force: force invite even if business_status is not 'free' (for re-invite requests). set -euo pipefail +DB_FILE="./db/customer.db" + PEER="" USER_ID_EXTERNAL="" GROUP_NAME="风暴眼(wiseflow情报小站)" +FORCE="" while [ $# -gt 0 ]; do case "$1" in @@ -22,6 +26,10 @@ while [ $# -gt 0 ]; do GROUP_NAME="${2:-}" shift 2 ;; + --force) + FORCE="1" + shift + ;; *) echo "Unknown argument: $1" >&2 exit 1 @@ -42,19 +50,32 @@ fi WORKDIR="$(cd "$(dirname "$0")/../../.." && pwd)" cd "$WORKDIR" -./skills/customer-db/scripts/db.sh ensure >/dev/null +if [ ! -f "$DB_FILE" ]; then + echo "❌ Database not found: $DB_FILE" >&2 + exit 1 +fi -existing_status="$(./skills/customer-db/scripts/db.sh sql "SELECT business_status FROM cs_record WHERE peer = '$PEER'" | tail -n +2 | head -n 1 || true)" +sql_quote() { + printf '%s' "$1" | sed "s/'/''/g" +} + +existing_status="$(sqlite3 "$DB_FILE" "SELECT business_status FROM cs_record WHERE peer = '$(sql_quote "$PEER")'" || true)" if [ -z "$existing_status" ]; then - ./skills/customer-db/scripts/db.sh sql "INSERT INTO cs_record (peer, business_status, purpose, prompt_source) VALUES ('$PEER', 'free', '', '')" >/dev/null + sqlite3 "$DB_FILE" "INSERT INTO cs_record (peer, business_status, purpose, prompt_source) VALUES ('$(sql_quote "$PEER")', 'free', '', '')" existing_status="free" fi -if [ "$existing_status" = "exp_invited" ]; then +# Block auto-invite for non-free users, unless --force is specified +if [ "$existing_status" != "free" ] && [ -z "$FORCE" ]; then echo "ALREADY_INVITED" exit 10 fi -./skills/customer-db/scripts/db.sh sql "UPDATE cs_record SET business_status = 'exp_invited' WHERE peer = '$PEER'" >/dev/null +# Only update business_status to exp_invited if current status is free or empty +# For exp_invited/subs/club users with --force, don't change business_status +if [ "$existing_status" = "free" ] || [ -z "$existing_status" ]; then + sqlite3 "$DB_FILE" "UPDATE cs_record SET business_status = 'exp_invited' WHERE peer = '$(sql_quote "$PEER")'" +fi + printf '/invite//%s//%s\n' "$USER_ID_EXTERNAL" "$GROUP_NAME" diff --git a/addons/officials/crew/selfmedia-operator/AGENTS.md b/addons/officials/crew/selfmedia-operator/AGENTS.md index 1dafb1a9..0a55136f 100644 --- a/addons/officials/crew/selfmedia-operator/AGENTS.md +++ b/addons/officials/crew/selfmedia-operator/AGENTS.md @@ -2,7 +2,7 @@ ## 素材积累 -素材积累来源包括:用户分享的飞书文档/网页链接、网络搜集、调用 skills 生成。 +素材积累来源包括:用户分享的飞书文档/网页链接、网络搜集、媒体文件等,或按用户要求使用相应技能生成的媒体文件。 **注意**:用户也可能时不时的通过私聊渠道分享一些要点、思路以及注意事项等,这些应该记在长期记忆 **MEMORY.md** 中。 @@ -18,51 +18,104 @@ index.md 格式为: - 来源:仅适用于用户分享和网络搜集 - prompt:仅适用于 skill 生成 -## 自媒体内容产出策略 +### 微信公众号内容对标 -### 核心原则 +如果用户提供了微信公众号账号或者微信公众号文章链接("https://mp.weixin.qq.com/"开头),可以使用 `generate-wenyan-theme` 技能,参考用户提供的账号或公众号文章,创建相似的公众号排版模板 -每篇文章或视频内容中都必须自然而合理的包含结合 wiseflow 的内容。生产前先回顾下 `MEMORY.md` +### 小红书内容对标 -### 文章(图文)内容生产通用约束 +如果用户需要对小红书图文内容进行对标,可以使用`xhs-content-ops`技能 -为每篇文章在 `output_articles/` 下创建独立文件夹作为工作区,结构如下: +## 自媒体内容产出 + +### 文章(图文)内容生产 + +用户会给出一个主题或写作思路,同时可能给出相关的参考资料(一段话、参考文章、图、视频等)。 + +这种情况下需要先为每篇文章在 `output_articles/` 下创建独立文件夹作为工作区,结构如下: ``` output_articles/ └── / # 文章英文题目作为文件夹名 - ├── article.md # 文章正文 + ├── article.md # 文章正文(按用户要求,结合用户给的资料书写) ├── cover.jpg # 封面图(必须) ├── img1.jpg # 配图1 ├── img2.jpg # 配图2 └── ... ``` -每篇文章都要有配图,包括封面图和正文配图 - **配图要求**: - -配图类型优先级: - - 1. **素材图**:日常积累的素材图,尤其是用户分享的 +- 每篇文章都要有配图,包括封面图和正文配图 +- 配图类型优先级: + - 1. 用户提供的素材。 + - 2. **素材图**:日常积累的素材图,尤其是用户分享的 - 存放在 `campaign_assets/` 目录 - - 直观展示 wiseflow 的能力和实际效果 - - 2. **技能生成图片**: + - 3. **技能生成图片**: - 优先使用 siliconflow-img-gen 生成,siliconflow-img-gen 不可用时,尝试 pexels-footage 或 pixabay-footage 下载免版权图片 -## Publish Strategy(发布执行策略) +按需写作的文章生产后**主动询问用户是否需要打分流程**。后续按用户决策推进(每一步决策由用户做): + +> 打分脚本(`score-only.sh`/`cal-toggle.sh`)与盲打分规范来自 `content-calibrator` 技能,发布记录脚本(`record.sh`)来自 `published-track` 技能,发布则依据各个平台发布技能。 + +1. **问是否打分**。 + - 用户说**要打分** → 对 `article.md` 执行打分评估:主 agent `sessions_spawn` blind sub-agent(只喂 `article.md` + `calibration//rubric_notes.md`,输出 7 维分)→ 使用 `score-only.sh` 校验 + 判阈值门。平台未启用 calibration → 跳过打分并告知用户。 + - 每轮打分后,**询问用户是否发布**。 + - 用户有意见,则按用户意见修改之后再次执行打分流程,直到用户确认可发布。 + - 用户说**发布** → 调对应发布技能发布 → 用 `record.sh` 记录,**把打分拿到的 7 维分一并传入**。 + - 用户说**不必打分直接发布** → 直接调发布技能发布 → 用 `record.sh` 记录(不传 `--cal-*`,`cal_enabled=0`)。 +2. 发布到哪个平台、是否多平台,由用户指定,因为涉及到用户交互和浏览器操作,所以多平台发布必须串行执行。 +3. 打分阈值取自 `calibration//.cheat-state.json` 的 `score_threshold`(默认 0=不拦截),每维需 > 阈值。打分流程与阈值命令见 `content-calibrator/SKILL.md`,发布记录见 `published-track/SKILL.md`。 + +### 视频内容生产 + +**视频制作统一使用 `video-product` 技能**,它在 `video_generate` 工具基础上提供了素材获取、脚本编写、用户确认、合成组装、封面图制作等作业流程的指导,必须严格遵守。 + +支持按如下四种输入制作视频: +1. 文章链接(网页URL、本地文件、微信公众号文章) +2. 追爆分析(使用 `viral-chaser` 技能获取追爆报告,再进入 `video-product` 流程) +3. 文字主题(用户直接给出主题或写作思路) +4. 用户已有素材(视频文件、图片参考) + +### 视频剪辑加工 + +你目前拥有两种简单的视频剪辑加工能力,适用于用户提供了原始视频素材,需要你帮忙进行剪辑的情况。 -### 文末统一宣传 hook +- `de-mouth` 技能用于处理口播视频,自动识别并删除静音、语气词、卡顿词、重复句、残句等,输出干净视频+字幕+剪映草稿。 +- `highlight-clipper` 技能用于自动从本地视频中提取高光片段。通过 ASR 转录 + 文本分析识别高光时刻,剪辑输出多段短视频。 -所有对外发布文章(视频则为简介区),必须按如下平台策略在文末添加统一宣传hook: +### 视频发布流程 - +> 打分脚本与盲打分规范来自 `content-calibrator` 技能,发布记录脚本(`record.sh`)来自 `published-track` 技能,发布则依据各个平台发布技能。 -### 发布记录管理 +当用户确认视频制作内容后。先参考 `output_videos//scripts.md` 草拟视频发布的题目和简介以及hashtag。视频简介中应提及Wiselow,但不要有明显引流信息,更加禁止放二维码、联系方式等 + +拟好后分别创建subagent(self-spawn)按用户指定发布的平台调用对应技能进行发布。但是对于使用浏览器自动化进行发布的技能(`twitter-post`, `wechat-channels-publish`)不可并行进行,避免浏览器资源竞态。 + +你要负责跟进各个subagent的进展,避免他们长时间卡住,有问题及时反馈。如果某一个平台缺乏登录的credentials,或者浏览器缺乏登录态,及时反馈用户,让用户提供。用户提供后,你要按技能要求存储下来,以便后续使用。 + +#### 发布后数据记录流程(除用户要求或特殊说明外都应执行) + +> 如果用户或者任务描述明确说**不记录** → 不调 `record.sh`, 发布流程结束 + +发布后再次读取 `output_videos//script.md` 末尾的 `## calibration_scores` 区段,取目标平台对应的分数, 之后执行`published-track`技能中的`record.sh`: + +**有分数 → 带分记录**(`cal_enabled=1` + 算 composite);**无分数 → 不带分记录并告知用户**(`cal_enabled=0`)。分数由 `video-product` 技能 Step 2.4 在脚本定稿后写入 `script.md`。 + +## 发布记录管理与复盘 **统一使用 `published-track` 技能管理所有发布记录**。 - 数据库位置:`./db/published_track.db`(初始化:`./skills/published-track/scripts/init-db.sh`,幂等可重复执行) -- 按平台分表,每张表包含标题、类型、原始文件夹、发布 URL、发布日期、互动指标等字段 -- 所有发布技能在发布成功后必须自动调用 `record.sh` 记录 -- 数据更新通过 `update-metrics.sh` 完成(心跳巡检时使用) -- 查询通过 `query.sh` 和 `check-published.sh` 完成 \ No newline at end of file +- 按平台分表,每张表包含标题、类型、原始文件夹、发布 URL、发布日期、互动指标、校准打分等字段 +- 数据更新通过 `update-metrics.sh` 完成(每日定时任务触发,或按用户要求录入用户提供数据) + +### 查询与平台设置(published-track 第三大块) + +日常按需调用 `published-track` 提供的查询与设置脚本: + +- **查询待分发**:`query-pending.sh`(白天 heartbeat 分发任务用) +- **分发状态设置**:`set-distribute-status.sh`(`--status 0/1/2`、`--mark-all-distributed`) +- **平台打分开关 + 阈值**:`cal-toggle.sh`(`--enable/--disable/--status/--threshold/--set-threshold N/--list`)。阈值语义:每维需 > `score_threshold`(默认 0=不拦截)。Agent 不得自动启用某平台打分或自动改阈值,需告知用户由用户决定;复盘后可向用户推荐阈值。 +- **通用查询**:`query.sh`、`check-published.sh`(按需自查是否已发布、读记录) + +平台初始化与是否开启打分,具体见 `content-calibrator` 技能。 \ No newline at end of file diff --git a/addons/officials/crew/selfmedia-operator/ALLOWED_COMMANDS b/addons/officials/crew/selfmedia-operator/ALLOWED_COMMANDS index 67d59de9..8d5008d4 100644 --- a/addons/officials/crew/selfmedia-operator/ALLOWED_COMMANDS +++ b/addons/officials/crew/selfmedia-operator/ALLOWED_COMMANDS @@ -1,3 +1,41 @@ # T2 基线已含 python3/node/npx/ffmpeg/sed/curl,无需重复声明 +cut +base64 ++nano-pdf ++jq ++rg ++tmux ++curl ++summarize ++gifgrep ++node ++python3 ++/home/wukong/.openclaw/skills/wx-mp-hunter/scripts/wx-mp-hunter.sh +# published-track ++./skills/published-track/scripts/init-db.sh ++./skills/published-track/scripts/record.sh ++./skills/published-track/scripts/update-metrics.sh ++./skills/published-track/scripts/query.sh ++./skills/published-track/scripts/check-published.sh ++./skills/published-track/scripts/query-pending.sh ++./skills/published-track/scripts/set-distribute-status.sh ++./skills/published-track/scripts/cal-toggle.sh ++./skills/published-track/scripts/score-only.sh ++./skills/published-track/scripts/migrate-v2.sh ++./skills/published-track/scripts/fetch-and-update-metrics.sh +# content-calibrator ++./skills/content-calibrator/scripts/init.sh ++./skills/content-calibrator/scripts/score-and-record.sh ++./skills/content-calibrator/scripts/query-metrics.sh ++./skills/content-calibrator/scripts/build-calibration-pool.sh ++./skills/content-calibrator/scripts/import-viral-chaser.sh +# wx-mp-publisher ++./skills/wx-mp-publisher/scripts/publish-wx-mp.sh +# de-mouth ++./skills/de-mouth/scripts/de_mouth.py +# viral-chaser ++./skills/viral-chaser/scripts/viral_chaser.sh +# xhs-content-ops ++./skills/xhs-content-ops/scripts/fetch_note_content.sh ++/home/wukong/.openclaw/skills/login-manager/scripts/login-manager.sh ++/home/wukong/.openclaw/skills/login-manager/scripts/export-cookies.sh diff --git a/addons/officials/crew/selfmedia-operator/BUILTIN_SKILLS b/addons/officials/crew/selfmedia-operator/BUILTIN_SKILLS index a198c3e6..658ebc7c 100644 --- a/addons/officials/crew/selfmedia-operator/BUILTIN_SKILLS +++ b/addons/officials/crew/selfmedia-operator/BUILTIN_SKILLS @@ -1,5 +1,6 @@ wx-mp-publisher twitter-post +twitter-interact douyin-publish youtube-publish tiktok-publish @@ -9,11 +10,18 @@ threads-publish pinterest-publish kuaishou-publish bilibili-publish +xhs-publish wxwork-moments xhs-content-ops xhs-interact +login-manager juejin-publish toutiao-publish pexels-footage pixabay-footage +viral-chaser highlight-clipper +published-track +content-calibrator +de-mouth +video-product \ No newline at end of file diff --git a/addons/officials/crew/selfmedia-operator/HEARTBEAT.md b/addons/officials/crew/selfmedia-operator/HEARTBEAT.md index ccd7633a..78ad9a53 100644 --- a/addons/officials/crew/selfmedia-operator/HEARTBEAT.md +++ b/addons/officials/crew/selfmedia-operator/HEARTBEAT.md @@ -1,19 +1,216 @@ -# 心跳任务 +# 心跳/定时任务 -## 执行约束 +## 凌晨复盘任务 -1. **无时间限制**:HEARTBEAT/cron 触发后必须执行完清单全部内容 -2. **遇到技术故障时**: - - 先尝试关闭并重启浏览器 - - 仍不解决 → spawn IT Engineer 协助 - - 仍无法解决 → 跳过当前任务,继续后续步骤,不卡住整个流程 -3. **不可呼唤用户协助**(定时任务可能深夜执行) -4. **浏览器操作必须串行**,不可并行,避免竞态抢夺 +### 执行约束 + +1. **无时间限制**:任务执行不受深夜时间限制,必须执行完 HEARTBEAT 清单全部内容 + +2. **遇到技术故障时处理方案**: + + - 先尝试彻底关闭浏览器,再打开(使用默认 `openclaw` profile); + - 重启浏览器不解决问题时,**spawn IT Engineer**协助解决:调用 `sessions_spawn`,将问题现象、错误信息、当前任务上下文完整传递给 IT Engineer,请它协助解决; + - 仍无法解决 → **跳过当前任务,继续执行后续步骤**,不要卡住整个 HEARTBEAT + + 不可: + - ❌ 呼唤用户协助解决,HEARTBEAT 在深夜执行,喊用户也没用 + - ❌ 不可中断任务,通过以上三步依然无法进行的任务则跳过,继续执行后续步骤,绝对不允许中断HEARTBEAT! + +3. HEARTBEAT 任务涉及大量浏览器操作,因此涉及浏览器的 subagent 任务不能并行发,必须排队串行执行,避免浏览器竞态抢夺。 + +--- + +### 工作流程 + +#### Step 0:准备工作 + +为避免浏览器连接不稳干扰后续任务执行,正式开始前应该将已经打开的浏览器实例(如有)先完全关闭,再打开。 + +打开时使用默认 `openclaw` profile。 --- -## 当前无定时任务 +#### Step 1: 通过 published-track 读取所有已启用打分(cal_enabled=1)的已发布内容 + +```bash +# 查看哪些平台启用了 content-calibrator +./skills/content-calibrator/scripts/cal-toggle.sh --list + +# 对每个已启用平台,查询有 cal_enabled=1 的记录 +./skills/published-track/scripts/query.sh --platform xhs --limit 50 +``` + +对每个已启用平台,列出所有 `cal_enabled=1` 的记录,准备在 Step 2 中更新数据。 + +--- + +#### Step 2: 依次获取已发布内容的互动数据并更新到 published-track + +对 Step 1 中列出的**每条记录(按 id 逐条)**,统一通过 `fetch-and-update-metrics.sh` 获取数据: + +```bash +./skills/published-track/scripts/fetch-and-update-metrics.sh \ + --platform --id +``` + +`` 取自 Step 1 查询结果里的 `id` 字段。**必须传 `--id`**:同一 `source_folder` 可能对应多条记录(同内容重复发布到不同帖子),按 `--id` 逐条抓取/写库才能让每次发布各自独立统计;若只传 `--source-folder`,脚本会只抓一行指标却批量写进所有同 folder 行,造成重复发布之间互相污染。 + +该脚本封装了 login-manager 探活 → API 抓取 → DB 写入 的完整流程,返回统一 JSON 结果。 + +##### 平台数据获取方案 + +| 平台 | 方案 | 实现路径 | 需登录 | 心跳可用 | +|------|------|---------|--------|---------| +| 小红书 (xhs) | **浏览器取 xsec_token + 脚本抓 feed** | 见下方「小红书 xsec_token 获取流程」 | ✅ login-manager | ✅ | +| B站 (bilibili) | **脚本** | `fetch-and-update-metrics.sh` → fetch-retro-data.ts (WBI 公开 API) | ❌ 无需 | ✅ | +| 抖音 (douyin) | **脚本** | `fetch-and-update-metrics.sh` → fetch-retro-data.ts (a_bogus 签名) | ✅ login-manager | ✅ | +| 知乎 (zhihu) | **浏览器** | browser 导航到文章页 → snapshot 读赞同/评论/收藏数 | ✅ login-manager | ✅ | +| 今日头条 (toutiao) | **浏览器** | browser 导航到文章页 → snapshot 读阅读/评论/点赞数 | ✅ login-manager | ✅ | +| 掘金 (juejin) | **浏览器** | browser 导航到文章页 → snapshot 读阅读/点赞/评论数 | ❌ 无需 | ✅ | +| Twitter/X (twitter) | **浏览器** | twitter-interact 技能浏览推文详情 → 读 views/likes/retweets/replies/bookmarks | ✅ login-manager | ✅ | +| YouTube (youtube) | **浏览器** | browser 导航到视频页 → snapshot 读观看/点赞/评论数 | ❌ 无需 | ✅ | +| 微信公众号 (wx_mp) | **跳过** | 无法自动获取 | — | ❌ | +| Facebook/Instagram/TikTok/Pinterest/Threads | **浏览器** | browser 导航 → snapshot | ✅ login-manager |✅ | + +##### 执行流程 + +> **小红书 (xhs) 走专属流程**,不套用下面的通用流程:见下方「小红书 xsec_token 获取流程」。其他平台按以下通用流程。 + +对每条记录(xhs 除外): + +1. **调 `fetch-and-update-metrics.sh --platform

--source-folder `** +2. 解析返回的 JSON: + - `ok=true, method=script` → **数据已自动获取并写入 DB**,完成 + - `ok=false, error=SESSION_EXPIRED` → **跳过该平台**,记录到 `EXPIRED_PLATFORMS` 列表,**不唤醒用户**(凌晨执行,用户白天处理) + - `ok=false, method=browser` → **走浏览器获取流程**(见下方) + - `ok=false, method=manual` → **跳过**(心跳中无法手动提供数据) + - 其他错误 → 记录到 `FAILED_ITEMS` 列表,跳过继续 + +##### 浏览器获取流程(method=browser 的平台) + +1. 如需 cookie:`login-manager check `,SESSION_EXPIRED 则跳过并记录 +2. browser 导航到 `publish_url`(从 DB 读取) +3. snapshot 读取页面上的互动指标 +4. Twitter/X 特殊:使用 **twitter-interact** 技能浏览推文详情页获取互动数据 +5. 调 `update-metrics.sh` 写入 DB: + ```bash + ./skills/published-track/scripts/update-metrics.sh \ + --platform --source-folder \ + --views 1234 --likes 56 ... + ``` + +##### 小红书 xsec_token 获取流程(xhs 专属) + +小红书 feed API 现在强制要求 `xsec_token`,而 xsec_token 无法通过 API 纯净获取(`user_posted` 端点已 406),只能从浏览器 DOM 取。复盘时 xhs 单独走以下流程: + +1. **取 self user_id**(可缓存,cookie 换了才需 `--refresh`): + ```bash + UID=$(./skills/published-track/scripts/get-xhs-user-id.sh) + ``` + 失败(exit 2 = cookie 失效)→ 记入 `EXPIRED_PLATFORMS`(平台名 `xhs-browse`),跳过 xhs。 + +2. **浏览器取 note_id → xsec_token 映射**: + - browser 导航到 `https://www.xiaohongshu.com/user/profile/${UID}`,等笔记卡片渲染出来 + - 用 browser evaluate 执行下面这段 JS,拿到 JSON 映射字符串: + ```js + // 小红书 profile 页笔记数据在 __INITIAL_STATE__.user.notes, + // 是「按 tab 分组的二维数组」(5 个 tab,每组是笔记列表),需 flatten。 + // 每条笔记字段为 id + xsecToken(驼峰,在 note 对象顶层,不在 noteCard 里)。 + // notes / 各分组 / 各笔记都可能是 Vue ref,统一用 unref 解包。 + (() => { + const unref = v => (v && v.__v_isRef && v._rawValue !== undefined) ? v._rawValue : v; + const notes = unref(window.__INITIAL_STATE__?.user?.notes); + const map = {}; + if (Array.isArray(notes)) { + for (const grp of notes) { + const g = unref(grp); + if (!Array.isArray(g)) continue; + for (const n of g) { + const nn = unref(n); + const nid = nn.id, tok = nn.xsecToken; + if (nid && tok) map[nid] = { xsec_token: tok, xsec_source: nn.xsecSource || "" }; + } + } + } + return JSON.stringify(map); + })() + ``` + - `xsec_source` 在 note 对象上没有,留空即可(fetch-retro-data.ts 会默认 `pc_feed`,已验证可用) + - 若某条 cal_enabled=1 记录的 note_id 不在映射里,向下滚动加载更多后再次 evaluate(最多 3 屏) + +3. **逐条抓 feed**:对每条 cal_enabled=1 的 xhs 记录,从 `publish_url` 用 `/explore/(note_id)` 正则提取 note_id,查映射拿 `xsec_token`/`xsec_source`,调: + ```bash + ./skills/published-track/scripts/fetch-and-update-metrics.sh \ + --platform xhs --id \ + --xsec-token --xsec-source + ``` + `` 取自该条记录的 `id`(按主键写单行,重复发布各自独立)。 + 解析返回 JSON: + - `ok=true` → 完成 + - `ok=false, error=NOTE_INACCESSIBLE` → xsec_token 失效或笔记异常,记入 `FAILED_ITEMS` + - `ok=false, error=SESSION_EXPIRED` → 记入 `EXPIRED_PLATFORMS`(`xhs-browse`),跳过 xhs + - note_id 在映射里找不到 → 记入 `FAILED_ITEMS`,原因写"profile 页未加载到该笔记" + +##### 注意事项 + +- **SESSION_EXPIRED 处理**:心跳在凌晨执行,Cookie 失效时**不唤醒用户**,跳过该平台,在 Step 5 汇总时报告,用户白天使用 login-manager 重新登录 +- **浏览器操作必须串行**,不可并行 +- 微信公众号**直接跳过**,其互动数据无法通过任何自动化手段获取 +- **探活只针对取数端 cookie**:xhs 取数探活只用 `xhs-browse`(`fetch-and-update-metrics.sh` 内部已自动调 `login-manager check xhs-browse`)。**禁止额外调 `login-manager check xhs` 或 `check xhs-publish`**——前者与 xhs-browse 重复探浏览域且 `xhs.json` 不存在恒失败,后者探 creator 发布域、与取数无关且增加风控概率。 + +--- + +#### Step 3: content-calibrator 复盘 + +对每个已启用 content-calibrator 的平台,检查是否满足复盘条件: + +1. 从 published-track DB 读取该平台所有 `cal_enabled=1` 的记录 +2. 检查 `calibration//predictions/` 中是否有对应的预测日志 +3. 统计**有实际互动数据但尚未复盘**的记录数 +4. 如果积累了 **≥5 个新数据点** → 执行复盘流程 + +复盘流程(由 Agent 执行): +- 从 published-track DB 读互动数据 +- 对比预测 vs 实际 +- 提炼观察 → 写入 `calibration//rubric-memo.md` +- 检测是否触发 bump(≥3 次同向偏差) + +**如果某平台未启用 content-calibrator,跳过此步骤。Agent 不得自动启用。** + +--- + +#### Step 4: 用户咨询回复 + +> 现阶段暂时跳过 + +巡检如下平台:,针对项目咨询类的留言、回复、私信进行简短回复,如: + +``` +项目那里下载? +怎么用? +代码仓在哪里? +支持 xxx 功能吗? +... +``` + +--- + +#### Step 5: 汇总执行情况报告用户 + +汇总执行情况,反馈用户。报告内容: -如有任务需求,向用户了解清楚后,参照 `HEARTBEAT_TEMPLATE.md` 的格式写入对应工作模式配置。 +1. 各平台数据更新情况(成功/跳过/失败数量) +2. **取数端 Cookie 失效列表**(如有): + > ⚠️ 以下**取数端**Cookie 已失效,数据未能更新。请白天使用 login-manager 技能重新登录: + > - douyin(抖音) + > - xhs-browse(小红书浏览端) + > + > 列出的名字即 `login-manager login ` 要用的平台名(非 published-track 的 `xhs`)。 + > + > **只报告取数端 cookie**。**不要报告、也不要探测 `xhs-publish`(小红书发布端 / creator.xiaohongshu.com)**: + > 复盘/取数完全不依赖发布端 cookie,探测它只会给 creator 域增加风控概率且结论与取数无关。 + > 发布端失效由发布任务(xhs-publish 技能)自己管,不在本复盘心跳职责内。 +3. 浏览器获取结果摘要(如有) +4. content-calibrator 复盘结果摘要(如有) -当前:回复 `HEARTBEAT_OK` +发送后本次定时任务结束。 diff --git a/addons/officials/crew/selfmedia-operator/IDENTITY.md b/addons/officials/crew/selfmedia-operator/IDENTITY.md index 1ceb0ce3..5b49047d 100644 --- a/addons/officials/crew/selfmedia-operator/IDENTITY.md +++ b/addons/officials/crew/selfmedia-operator/IDENTITY.md @@ -4,7 +4,7 @@ 小编 ## Role -业务驱动型自媒体内容专家 — 以推广公司产品与业务为核心目标,深耕主流自媒体生态,发现热点、采集素材、撰写图文、指导视频生产,交付可直接发布的内容,并深入自媒体平台上各个账号的运营。 +业务驱动型自媒体内容专家 — 以推广公司产品与业务为核心目标,深耕主流自媒体生态,发现热点、采集素材、撰写图文、视频生产,交付可直接发布的内容,并深入自媒体平台上各个账号的运营。 ## Personality 贴地气、有洞察力、执行力强。能感知平台气氛和受众喜好,把枯燥的信息变成有传播力的图文或视频。讲究效率,稿件出炉前必请用户确认。 diff --git a/addons/officials/crew/selfmedia-operator/SOUL.md b/addons/officials/crew/selfmedia-operator/SOUL.md index fb4f2a4d..1312287b 100644 --- a/addons/officials/crew/selfmedia-operator/SOUL.md +++ b/addons/officials/crew/selfmedia-operator/SOUL.md @@ -7,8 +7,6 @@ ## 公司与业务背景信息 - - ## Core Responsibilities ### 素材管理 diff --git a/addons/officials/crew/selfmedia-operator/TOOLS.md b/addons/officials/crew/selfmedia-operator/TOOLS.md index b443d4d2..4ad93a21 100644 --- a/addons/officials/crew/selfmedia-operator/TOOLS.md +++ b/addons/officials/crew/selfmedia-operator/TOOLS.md @@ -3,3 +3,25 @@ ## 环境备注 - 文生图/改图默认输出 JPG 格式:企业微信后台发送图片只支持 JPG;如需 PNG 需显式指定 --format png + +### 📝 视频封面/海报制作经验 + +`siliconflow-img-gen` 可以很好的直接出带文字的海报,完全不必要先生成图,然后自己再编写脚本拼字。 + +具体见 `siliconflow-img-gen` 技能中 `视频封面/海报最佳实践`。 + +但是**绝对不要用 image_generate 工具(默认 minimax/image-01)直接出带字图片!** + +实际测试下来`minimax/image-01`无法正确出带文字的图片。 + +### 数据库查询一定走 published-track 脚本 + +`sqlite3` 不在 allowlist 中。查询 published-track 数据库必须通过已有脚本: + +``` +✅ ./skills/published-track/scripts/query.sh --platform wx_mp +✅ ./skills/published-track/scripts/query-pending.sh + +❌ sqlite3 db/published_track.db "SELECT ..." +❌ echo ".tables" | sqlite3 db/published_track.db +``` \ No newline at end of file diff --git a/addons/officials/crew/selfmedia-operator/openclaw_setting_sample.json b/addons/officials/crew/selfmedia-operator/openclaw_setting_sample.json index 211a30ae..ea6a8108 100644 --- a/addons/officials/crew/selfmedia-operator/openclaw_setting_sample.json +++ b/addons/officials/crew/selfmedia-operator/openclaw_setting_sample.json @@ -6,6 +6,7 @@ "youtube-upload", "xhs-content-ops", "xhs-interact", + "xhs-publisher", "juejin-publish", "toutiao-publish", "siliconflow-img-gen", @@ -14,8 +15,12 @@ "smart-search", "browser-guide", "wxwork-moments", + "wx-mp-hunter", "wxwork-drive", + "login-manager", "wx-mp-publisher", + "video-product", + "viral-chaser", "complex-task" ], "subagents": { diff --git a/addons/officials/crew/selfmedia-operator/skills/douyin-publish/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/douyin-publish/SKILL.md index 673b37e6..917d7e90 100644 --- a/addons/officials/crew/selfmedia-operator/skills/douyin-publish/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/douyin-publish/SKILL.md @@ -1,8 +1,9 @@ --- name: douyin-publish -description: Publish videos to Douyin (抖音) via open platform API with OAuth2 authentication. - Supports video upload, cover image, hashtags, and privacy settings. Requires Douyin - open platform OAuth2 credentials. +description: Publish content to Douyin (抖音) via open platform H5 Schema. + Generates a schema URL to open Douyin app's publish page. Supports video, + images, album, hashtags, privacy, note mode, and forward-to-daily. Requires + Douyin open platform web app credentials. metadata: openclaw: emoji: 🎤 @@ -11,17 +12,20 @@ metadata: - python3 --- -# 抖音视频发布(douyin-publish) +# 抖音内容发布(douyin-publish) -通过抖音开放平台 API 发布视频,支持视频上传、封面、话题标签。使用 OAuth2 认证。 +通过抖音开放平台 H5 Schema 发布内容(视频/图片/图集),生成 schema URL 唤起抖音 App 完成发布。 --- ## 前置条件 -1. 抖音开放平台创建应用,获取 client_key / client_secret -2. 申请「视频管理」权限范围 -3. 首次运行需浏览器授权,后续自动使用 refresh token +1. 在 [抖音开放平台](https://open.douyin.com) 创建**网页应用**,获取 `client_key` / `client_secret` +2. 申请 `h5.share` 能力权限(H5 场景分享/发布) +3. 投稿能力(抖音 30.5.0+):额外申请 `aweme.share` 权限 +4. 转发到日常能力(抖音 30.5.0+):额外申请 `aweme.forward` 权限 + +> **H5 Schema 方式不需要用户 OAuth2 授权**,使用 client_token(client_credential 授予)即可生成 schema。用户在手机端打开 schema 后由抖音 App 处理发布。 --- @@ -32,13 +36,10 @@ metadata: ```json { "client_key": "your_client_key", - "client_secret": "your_client_secret", - "redirect_uri": "https://localhost:8080/callback" + "client_secret": "your_client_secret" } ``` -首次授权后 token 保存到 `~/.openclaw/credentials/douyin_token.json` - --- ## 使用方式 @@ -46,34 +47,55 @@ metadata: ```bash python3 ./skills/douyin-publish/scripts/publish_douyin.py \ --title "视频标题" \ - --video video.mp4 \ - --cover cover.jpg \ + --video "https://example.com/video.mp4" \ --tags 话题1,话题2 ``` +图集模式: + +```bash +python3 ./skills/douyin-publish/scripts/publish_douyin.py \ + --title "图文标题" \ + --images "https://example.com/img1.jpg,https://example.com/img2.jpg" \ + --tags 话题1 +``` + --- ## 参数说明 | 参数 | 必填 | 说明 | |------|------|------| -| `--title` | 是 | 视频标题,最多 55 字 | -| `--video` | 是 | 视频文件路径,支持 mp4,建议 9:16 | -| `--cover` | 否 | 封面图 URL 或本地路径 | +| `--title` | 是 | 内容标题 | +| `--video` | 否* | 视频 URL(公网可访问,mp4/mov,≤128M) | +| `--image` | 否* | 单张图片 URL(png/jpg/gif,≤20M) | +| `--images` | 否* | 逗号分隔的图片 URL(图集模式,png/jpg,抖音 22.2.0+) | | `--tags` | 否 | 逗号分隔的话题标签 | -| `--private` | 否 | 设为仅自己可见 | +| `--short-title` | 否 | 短标题(抖音 30.0.0+) | +| `--private-status` | 否 | 可见范围:0=公开,1=仅自己,2=好友可见(抖音 30.0.0+) | +| `--download-type` | 否 | 下载控制:1=允许,2=不允许(抖音 30.0.0+) | +| `--share-to-type` | 否 | 发布类型:0=投稿,1=转发到日常(抖音 25.4.0+) | +| `--poi-id` | 否 | 地理位置 POI ID(抖音 22.2.0+) | +| `--feature` | 否 | 设为 `note` 启用笔记模式(抖音 30.3.0+,仅多图) | + +*\*`--video`、`--image`、`--images` 至少提供一个。`--video` 优先于图片参数。* + +> **重要**:`--video` / `--image` / `--images` 均为**公网可访问的 URL**,不是本地文件路径。需先将媒体文件上传到可公网访问的位置。 --- ## Agent 工作流 -1. 检查抖音配置和 token -2. 准备视频 + 标题 +1. 检查抖音配置(`douyin_config.json`) +2. 确保视频/图片已上传到公网可访问的 URL 3. 运行 `publish_douyin.py` 脚本 4. 检查 stdout JSON 输出: - - `{"ok": true, "item_id": "xxx", "url": "https://www.douyin.com/video/xxx"}` → 成功 - - `{"ok": false, "error": "AUTH_REQUIRED"}` → 需要完成 OAuth2 授权 + - `{"ok": true, "schema_url": "snssdk1128://...", "share_id": "xxx"}` → 成功生成 schema + - `{"ok": false, "error": "CONFIG_MISSING"}` → 需要配置凭据 - `{"ok": false, "error": "..."}` → 其他错误 +5. 将 `schema_url` 提供给用户,用户在手机上打开(扫码或点击链接) +6. 用户在抖音 App 中确认发布 +7. 可通过 `share_id` 调用「查询视频分享结果」API 获取发布状态 --- @@ -81,26 +103,9 @@ python3 ./skills/douyin-publish/scripts/publish_douyin.py \ | 错误 | 原因 | 处理 | |------|------|------| -| AUTH_REQUIRED | 无有效 OAuth2 token | 提示用户完成授权 | -| UPLOAD_FAILED | 上传失败 | 检查文件格式,重试一次 | -| PUBLISH_FAILED | 发布失败 | 检查权限和内容合规性 | -| QUOTA_EXCEEDED | API 调用频率限制 | 等待后重试 | - -## 发布记录(强制) - -发布成功后,**必须**立即调用 `published-track` 技能记录发布信息: - -```bash -./skills/published-track/scripts/record.sh \ - --platform douyin \ - --title "标题" \ - --content-type video \ - --source-folder "<原始文件夹路径>" \ - --publish-url "<发布URL>" \ - --publish-date "$(date +%Y-%m-%d)" -``` - -`--source-folder` 为原始内容所在的相对路径(如 `output_articles/xxx` 或 `output_videos/xxx`)。 -`--publish-url` 为发布后获得的 URL,若发布失败则留空并在 `--notes` 中注明原因。 - -执行 `./skills/published-track/scripts/init-db.sh`(幂等,重复执行无副作用)。 +| CONFIG_MISSING | 无 douyin_config.json | 创建配置文件 | +| CONFIG_INVALID | 缺少 client_key 或 client_secret | 补全配置 | +| CLIENT_TOKEN_FAILED | 获取 client_token 失败 | 检查凭据是否正确、应用是否审核通过 | +| TICKET_FAILED | 获取 ticket 失败 | 检查应用是否审核通过、h5.share 权限是否已申请 | +| SHARE_ID_FAILED | 获取 share_id 失败 | 检查 h5.share 权限 | +| MISSING_MEDIA | 未提供视频或图片 | 至少提供一种媒体内容 | diff --git a/addons/officials/crew/selfmedia-operator/skills/douyin-publish/scripts/publish_douyin.py b/addons/officials/crew/selfmedia-operator/skills/douyin-publish/scripts/publish_douyin.py index 4ec96953..4e566b99 100755 --- a/addons/officials/crew/selfmedia-operator/skills/douyin-publish/scripts/publish_douyin.py +++ b/addons/officials/crew/selfmedia-operator/skills/douyin-publish/scripts/publish_douyin.py @@ -1,20 +1,28 @@ #!/usr/bin/env python3 -"""Publish videos to Douyin via open platform API with OAuth2.""" +"""Publish content to Douyin via H5 Schema (open platform). + +Generates a schema URL that opens the Douyin app's publish page. +The user must open this URL on a device with the Douyin app installed +to confirm and complete the publishing. + +Flow: + client_key + client_secret → client_token → ticket + share_id → schema URL +""" import argparse import hashlib import json -import os +import random +import string import sys import time -import uuid from pathlib import Path +from urllib.parse import quote, urlencode import requests CREDS_DIR = Path.home() / ".openclaw" / "credentials" CONFIG_FILE = CREDS_DIR / "douyin_config.json" -TOKEN_FILE = CREDS_DIR / "douyin_token.json" DOUYIN_API = "https://open.douyin.com" @@ -30,176 +38,288 @@ def err_exit(msg: str, code: int = 1) -> None: def load_config() -> dict: if not CONFIG_FILE.exists(): - err_exit("AUTH_REQUIRED: no douyin_config.json", 2) - return json.loads(CONFIG_FILE.read_text()) + err_exit( + "CONFIG_MISSING: no douyin_config.json. " + "Create ~/.openclaw/credentials/douyin_config.json with client_key and client_secret.", + 2, + ) + cfg = json.loads(CONFIG_FILE.read_text()) + if not cfg.get("client_key") or not cfg.get("client_secret"): + err_exit("CONFIG_INVALID: douyin_config.json must contain client_key and client_secret", 2) + return cfg -def load_token() -> dict: - if not TOKEN_FILE.exists(): - err_exit("AUTH_REQUIRED", 2) - return json.loads(TOKEN_FILE.read_text()) +def generate_nonce_str(length: int = 32) -> str: + chars = string.ascii_letters + string.digits + return "".join(random.choices(chars, k=length)) -def refresh_access_token(config: dict, token_data: dict) -> str: - refresh_token = token_data.get("refresh_token", "") - if not refresh_token: - err_exit("AUTH_REQUIRED: no refresh token", 2) +def get_client_token(config: dict) -> str: + """Get client_token via client_credential grant (no user auth needed). + client_token is valid for 2 hours. Repeated calls invalidate the previous + one (with a 5-minute buffer). Rate limit: 500 calls per 5 minutes. + """ resp = requests.post( - f"{DOUYIN_API}/oauth/refresh_token/", - data={ + f"{DOUYIN_API}/oauth/client_token/", + json={ "client_key": config["client_key"], "client_secret": config["client_secret"], - "grant_type": "refresh_token", - "refresh_token": refresh_token, + "grant_type": "client_credential", }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, + headers={"Content-Type": "application/json"}, timeout=30, ) if resp.status_code != 200: - err_exit(f"AUTH_REQUIRED: refresh failed: {resp.text}", 2) + err_exit(f"CLIENT_TOKEN_FAILED: HTTP {resp.status_code}: {resp.text[:200]}") data = resp.json() - token_info = data.get("data", {}) - if token_info.get("error_code") != 0: - err_exit(f"AUTH_REQUIRED: {token_info.get('description', data)}", 2) - - new_token = { - "access_token": token_info.get("access_token", ""), - "refresh_token": token_info.get("refresh_token", refresh_token), - "expires_in": token_info.get("expires_in", 0), - "open_id": token_info.get("open_id", token_data.get("open_id", "")), - } - CREDS_DIR.mkdir(parents=True, exist_ok=True) - TOKEN_FILE.write_text(json.dumps(new_token, indent=2)) - return new_token["access_token"] - - -def upload_video(access_token: str, open_id: str, video_path: str) -> str: - url = f"{DOUYIN_API}/api/douyin/v1/video/upload_video/" - file_size = os.path.getsize(video_path) - filename = os.path.basename(video_path) - - with open(video_path, "rb") as f: - resp = requests.post( - url, - headers={"access-token": access_token}, - params={"open_id": open_id}, - files={"video": (filename, f, "video/mp4")}, - timeout=300, - ) + if data.get("message") != "success": + desc = data.get("data", {}).get("description", str(data)) + err_exit(f"CLIENT_TOKEN_FAILED: {desc}") - if resp.status_code in (401, 403): - err_exit("AUTH_REQUIRED", 2) - data = resp.json() - if data.get("data", {}).get("error_code") != 0: - err_exit(f"UPLOAD_FAILED: {data.get('data', {}).get('description', data)}") - - video_id = data.get("data", {}).get("video", {}).get("video_id", "") - if not video_id: - err_exit(f"UPLOAD_FAILED: no video_id: {data}") - return video_id - - -def upload_cover(access_token: str, open_id: str, cover_path: str) -> str: - url = f"{DOUYIN_API}/api/douyin/v1/video/upload_cover/" - with open(cover_path, "rb") as f: - resp = requests.post( - url, - headers={"access-token": access_token}, - params={"open_id": open_id}, - files={"image": ("cover.jpg", f, "image/jpeg")}, - timeout=60, - ) - data = resp.json() - return data.get("data", {}).get("image", {}).get("image_url", "") + token = data.get("data", {}).get("access_token", "") + if not token: + err_exit("CLIENT_TOKEN_FAILED: no access_token in response") + return token -def create_video( - access_token: str, open_id: str, - video_id: str, title: str, cover_url: str, - tags: list[str], is_private: bool, -) -> dict: - url = f"{DOUYIN_API}/api/douyin/v1/video/create_video/" - text = title - if tags: - text += " " + " ".join(f"#{t}" for t in tags) +def get_open_ticket(client_token: str) -> str: + """Get open ticket for schema signature generation.""" + resp = requests.get( + f"{DOUYIN_API}/open/getticket/", + headers={ + "Content-Type": "application/json", + "access-token": client_token, + }, + timeout=30, + ) + if resp.status_code != 200: + err_exit(f"TICKET_FAILED: HTTP {resp.status_code}: {resp.text[:200]}") - payload = { - "video_id": video_id, - "text": text[:55], - } - if cover_url: - payload["cover_url"] = cover_url - if is_private: - payload["private"] = True + data = resp.json() + ticket = data.get("data", {}).get("ticket", "") + if not ticket: + err_exit("TICKET_FAILED: no ticket in response") + return ticket - resp = requests.post( - url, + +def get_share_id(client_token: str) -> str: + """Get share_id for tracking publish result via webhook/query.""" + resp = requests.get( + f"{DOUYIN_API}/share-id/", + params={"need_callback": "true"}, headers={ - "access-token": access_token, "Content-Type": "application/json", + "access-token": client_token, }, - params={"open_id": open_id}, - json=payload, timeout=30, ) + if resp.status_code != 200: + err_exit(f"SHARE_ID_FAILED: HTTP {resp.status_code}: {resp.text[:200]}") - if resp.status_code in (401, 403): - err_exit("AUTH_REQUIRED", 2) data = resp.json() - if data.get("data", {}).get("error_code") != 0: - msg = data.get("data", {}).get("description", str(data)) - if "login" in msg.lower() or "登录" in msg: - err_exit("AUTH_REQUIRED", 2) - err_exit(f"PUBLISH_FAILED: {msg}") + error_code = data.get("extra", {}).get("error_code", -1) + if error_code != 0: + desc = data.get("extra", {}).get("description", str(data)) + err_exit(f"SHARE_ID_FAILED: {desc}") + + share_id = data.get("data", {}).get("share_id", "") + if not share_id: + err_exit("SHARE_ID_FAILED: no share_id in response") + return share_id + + +def generate_signature(ticket: str, nonce_str: str, timestamp: str) -> str: + """Generate MD5 signature for H5 schema. + + Sign string: nonce_str={nonce_str}&ticket={ticket}×tamp={timestamp} + Result: MD5 hex digest of the sign string. + """ + sign_str = f"nonce_str={nonce_str}&ticket={ticket}×tamp={timestamp}" + return hashlib.md5(sign_str.encode()).hexdigest() + + +def build_schema_url( + client_key: str, + ticket: str, + share_id: str, + title: str, + video_path: str | None, + image_path: str | None, + image_list_path: list[str] | None, + hashtag_list: list[str] | None, + title_hashtag_list: list[dict] | None, + short_title: str | None, + private_status: int | None, + download_type: int | None, + share_to_type: int | None, + poi_id: str | None, + feature: str | None, +) -> str: + """Build the H5 share schema URL. + + Schema format: snssdk1128://openplatform/share?share_type=h5&client_key=xx&... + All parameters should be URL-encoded. Spaces encoded as %20 (not +). + """ + nonce_str = generate_nonce_str(32) + timestamp = str(int(time.time())) + signature = generate_signature(ticket, nonce_str, timestamp) + + params = { + "client_key": client_key, + "nonce_str": nonce_str, + "timestamp": timestamp, + "signature": signature, + "share_type": "h5", + } - item_id = data.get("data", {}).get("item_id", "") - return {"ok": True, "item_id": item_id, "url": f"https://www.douyin.com/video/{item_id}"} + if share_id: + params["state"] = share_id + + # Media content + if video_path: + params["video_path"] = video_path + params["share_to_publish"] = "1" + if image_path: + params["image_path"] = image_path + if image_list_path: + params["image_list_path"] = json.dumps(image_list_path, ensure_ascii=False) + + # Title + if title: + params["title"] = title + if short_title: + params["short_title"] = short_title + + # Hashtags + if hashtag_list: + params["hashtag_list"] = json.dumps(hashtag_list, ensure_ascii=False) + if title_hashtag_list: + params["title_hashtag_list"] = json.dumps(title_hashtag_list, ensure_ascii=False) + + # Privacy & download + if private_status is not None: + params["private_status"] = str(private_status) + if download_type is not None: + params["download_type"] = str(download_type) + if share_to_type is not None: + params["share_to_type"] = str(share_to_type) + + # Location + if poi_id: + params["poi_id"] = poi_id + + # Note mode + if feature: + params["feature"] = feature + + # Use quote_via=quote so spaces become %20 instead of + + return "snssdk1128://openplatform/share?" + urlencode(params, quote_via=quote) def main() -> None: - parser = argparse.ArgumentParser(description="Publish video to Douyin") - parser.add_argument("--title", required=True, help="Video title (max 55 chars)") - parser.add_argument("--video", required=True, help="Video file path") - parser.add_argument("--cover", help="Cover image path") - parser.add_argument("--tags", default="", help="Comma-separated tags") - parser.add_argument("--private", action="store_true", help="Set to private") + parser = argparse.ArgumentParser( + description="Publish content to Douyin via H5 Schema (open platform)" + ) + parser.add_argument("--title", required=True, help="Content title") + parser.add_argument( + "--video", + help="Video URL (publicly accessible, mp4/mov, max 128M)", + ) + parser.add_argument( + "--image", + help="Single image URL (png/jpg/gif, max 20M)", + ) + parser.add_argument( + "--images", + help="Comma-separated image URLs for album mode (png/jpg, Douyin 22.2.0+)", + ) + parser.add_argument("--tags", default="", help="Comma-separated hashtags") + parser.add_argument( + "--short-title", dest="short_title", + help="Short title (Douyin 30.0.0+)", + ) + parser.add_argument( + "--private-status", dest="private_status", type=int, choices=[0, 1, 2], + help="Visibility: 0=public, 1=self-only, 2=friends-only (Douyin 30.0.0+)", + ) + parser.add_argument( + "--download-type", dest="download_type", type=int, choices=[1, 2], + help="Download: 1=allow, 2=disallow (Douyin 30.0.0+)", + ) + parser.add_argument( + "--share-to-type", dest="share_to_type", type=int, choices=[0, 1], + help="Publish type: 0=post, 1=forward to daily (Douyin 25.4.0+)", + ) + parser.add_argument( + "--poi-id", dest="poi_id", + help="Location POI ID (Douyin 22.2.0+)", + ) + parser.add_argument( + "--feature", choices=["note"], + help="Set to 'note' for note mode with multi-image (Douyin 30.3.0+)", + ) args = parser.parse_args() - if not os.path.exists(args.video): - err_exit(f"UPLOAD_FAILED: video not found: {args.video}") + # Validate: at least one media required + if not args.video and not args.image and not args.images: + err_exit("MISSING_MEDIA: at least one of --video, --image, --images is required") config = load_config() - token_data = load_token() - try: - access_token = refresh_access_token(config, token_data) - except SystemExit: - raise - except Exception as e: - err_exit(f"AUTH_REQUIRED: {e}", 2) + # Step 1: Get client_token (no user auth needed) + sys.stderr.write("[douyin-publish] step 1/4: getting client_token...\n") + client_token = get_client_token(config) - open_id = token_data.get("open_id", "") - if not open_id: - err_exit("AUTH_REQUIRED: no open_id in token", 2) + # Step 2: Get open ticket for signature + sys.stderr.write("[douyin-publish] step 2/4: getting open ticket...\n") + ticket = get_open_ticket(client_token) - sys.stderr.write("[douyin-publish] uploading video...\n") - video_id = upload_video(access_token, open_id, args.video) - - cover_url = "" - if args.cover and os.path.exists(args.cover): - sys.stderr.write("[douyin-publish] uploading cover...\n") - cover_url = upload_cover(access_token, open_id, args.cover) + # Step 3: Get share_id for result tracking + sys.stderr.write("[douyin-publish] step 3/4: getting share_id...\n") + share_id = get_share_id(client_token) + # Build hashtag lists tags = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else [] - - sys.stderr.write("[douyin-publish] creating video post...\n") - result = create_video( - access_token, open_id, video_id, args.title, cover_url, - tags, args.private, + hashtag_list = tags if tags else None + title_hashtag_list = None + if tags: + # Place hashtags at end of title (AiToEarn convention) + title_hashtag_list = [{"name": tag, "start": len(args.title)} for tag in tags] + + # Build image list + image_list_path = None + if args.images: + image_list_path = [url.strip() for url in args.images.split(",") if url.strip()] + + # Step 4: Generate schema URL + sys.stderr.write("[douyin-publish] step 4/4: generating share schema...\n") + schema_url = build_schema_url( + client_key=config["client_key"], + ticket=ticket, + share_id=share_id, + title=args.title, + video_path=args.video, + image_path=args.image, + image_list_path=image_list_path, + hashtag_list=hashtag_list, + title_hashtag_list=title_hashtag_list, + short_title=args.short_title, + private_status=args.private_status, + download_type=args.download_type, + share_to_type=args.share_to_type, + poi_id=args.poi_id, + feature=args.feature, ) - output(result) + + sys.stderr.write(f"[douyin-publish] schema generated (share_id={share_id})\n") + output({ + "ok": True, + "schema_url": schema_url, + "share_id": share_id, + "hint": "Open schema_url on a device with Douyin app to complete publishing. Use share_id to query result later.", + }) if __name__ == "__main__": diff --git a/addons/officials/crew/selfmedia-operator/skills/facebook-publish/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/facebook-publish/SKILL.md index 716e72fc..586567c7 100644 --- a/addons/officials/crew/selfmedia-operator/skills/facebook-publish/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/facebook-publish/SKILL.md @@ -102,24 +102,3 @@ python3 ./skills/facebook-publish/scripts/publish_facebook.py \ | UPLOAD_FAILED | 上传失败 | 检查文件格式,重试 | | MEDIA_PROCESSING | 视频处理中 | 轮询状态等待完成 | | RATE_LIMIT | 频率限制 | 等待后重试 | - ---- - -## 发布记录(强制) - -发布成功后,**必须**立即调用 `published-track` 技能记录发布信息: - -```bash -./skills/published-track/scripts/record.sh \ - --platform facebook \ - --title "标题" \ - --content-type post \ - --source-folder "<原始文件夹路径>" \ - --publish-url "<发布URL>" \ - --publish-date "$(date +%Y-%m-%d)" -``` - -`--source-folder` 为原始内容所在的相对路径(如 `output_articles/xxx` 或 `output_videos/xxx`)。 -`--publish-url` 为发布后获得的 URL,若发布失败则留空并在 `--notes` 中注明原因。 - -执行 `./skills/published-track/scripts/init-db.sh`(幂等,重复执行无副作用)。 diff --git a/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/SKILL.md index 20528391..fe3597b4 100644 --- a/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/SKILL.md @@ -48,7 +48,7 @@ python3 ./skills/highlight-clipper/scripts/clip.py --out-dir output | `--out-dir` | — | 输出目录,必须在 `output_videos/` 或 `tmp/` 下(必需) | | `--count` | 3 | 提取高光片段数量 | | `--min-duration` | 15 | 最短片段时长(秒) | -| `--max-duration` | 60 | 最长片段时长(秒) | +| `--max-duration` | 58 | 最长片段时长(秒),默认 58 留余量防超 60s 平台限制 | | `--buffer` | 3 | 片段前后缓冲(秒) | 示例 — 从一段 5 分钟视频提取 5 个高光: @@ -116,8 +116,10 @@ cat output_videos//highlights.json - 疑问句和感叹号 — 权重 1.5 - 数据/数字出现 — 权重 1.0 - 信息密度(单位时长文字量)— 权重最高 3.0 -4. **多样性选择**:贪婪选取得分最高的 N 个片段,保证片段间至少间隔 30 秒,避免高光扎堆 -5. **视频剪辑**:ffmpeg 精确裁剪,含前后缓冲秒数 +4. **多样性选择**:贪婪选取得分最高的 N 个片段,保证片段间至少间隔 3 秒(仅去重) +5. **相邻合并**:间隔 ≤ 10 秒的高光片段自动合并为一段长片段,**不限时长**——挨着的高光连成完整段落 +6. **锚点扩展**:短片段(< max-duration)以片段为中心向前后扩展填满 max-duration;已合并的长片段不截断,保留完整内容 +7. **视频剪辑**:ffmpeg 精确裁剪 --- @@ -133,3 +135,5 @@ cat output_videos//highlights.json - 转录质量取决于语音清晰度,建议使用语音清晰的视频 - 高光评分基于文本语义分析,非视觉分析——画面精彩但无语音的片段可能被遗漏 - 片段时长受 `--min-duration` 和 `--max-duration` 控制,可根据目标平台要求调整(如抖音 15–60 秒、小红书 15–45 秒) +- 相邻高光片段(间隔 ≤ 10 秒)会自动合并,合并后不限时长——适合会议中连续精彩讨论的场景 +- 短片段会以片段为中心向前后扩展至 max-duration(默认 58 秒),确保每段内容充实 diff --git a/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/scripts/clip.py b/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/scripts/clip.py index 87b86579..1acccd6d 100644 --- a/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/scripts/clip.py +++ b/addons/officials/crew/selfmedia-operator/skills/highlight-clipper/scripts/clip.py @@ -33,8 +33,9 @@ DEFAULT_COUNT = 3 DEFAULT_BUFFER = 3.0 DEFAULT_CLIP_MIN = 15 -DEFAULT_CLIP_MAX = 60 -MIN_HIGHLIGHT_GAP = 30 # min seconds between highlight starts +DEFAULT_CLIP_MAX = 58 +MIN_HIGHLIGHT_GAP = 3 # min seconds between highlight starts (just dedup, merging handles proximity) +MERGE_GAP = 10.0 # merge highlights within this gap (seconds) SAFE_OUTPUT_DIRS = (Path("output_videos"), Path("tmp")) @@ -299,19 +300,67 @@ def select_highlights(segments: list[dict], count: int) -> list[dict]: return selected +def merge_nearby_highlights(highlights: list[dict], gap_threshold: float = MERGE_GAP) -> list[dict]: + """Merge highlights within gap_threshold of each other. No duration limit on merged clips.""" + if not highlights: + return [] + sorted_hl = sorted(highlights, key=lambda s: s.get("start", 0)) + merged: list[dict] = [dict(sorted_hl[0])] + for seg in sorted_hl[1:]: + prev = merged[-1] + prev_end = prev.get("end", 0) + curr_start = seg.get("start", 0) + if curr_start - prev_end <= gap_threshold: + merged[-1] = { + "start": prev.get("start", 0), + "end": max(prev_end, seg.get("end", 0)), + "text": prev.get("text", "") + " " + seg.get("text", ""), + "highlight_score": max(prev.get("highlight_score", 0), seg.get("highlight_score", 0)), + } + else: + merged.append(dict(seg)) + return merged + + # ── Video clipping ────────────────────────────────────────────────────── def determine_clip_bounds(seg: dict, video_duration: float, buffer: float, clip_min: float, clip_max: float) -> tuple[float, float]: + """Determine clip bounds with expansion for short segments and no truncation for merged long ones. + + - Short segment (duration < clip_max): expand to fill clip_max, centered on segment. + - Long/merged segment (duration >= clip_max): use full range + buffer, never truncate. + """ seg_start = seg.get("start", 0) seg_end = seg.get("end", 0) seg_duration = seg_end - seg_start - target = max(seg_duration + buffer * 2, clip_min) - target = min(target, clip_max) - clip_start = max(seg_start - buffer, 0) - clip_end = min(clip_start + target, video_duration) - if clip_end - clip_start < clip_min: - clip_start = max(clip_end - target, 0) + + if seg_duration >= clip_max: + # Merged/long segment — use full range + buffer, never truncate + clip_start = max(seg_start - buffer, 0) + clip_end = min(seg_end + buffer, video_duration) + else: + # Short segment — expand to fill clip_max, centered on segment + seg_center = (seg_start + seg_end) / 2 + ideal_start = seg_center - clip_max / 2 + ideal_end = seg_center + clip_max / 2 + + if ideal_start < 0: + clip_start = 0.0 + clip_end = min(clip_max, video_duration) + elif ideal_end > video_duration: + clip_end = video_duration + clip_start = max(video_duration - clip_max, 0.0) + else: + clip_start = ideal_start + clip_end = ideal_end + + # Guarantee the segment is fully inside the clip + if clip_start > seg_start: + clip_start = seg_start + if clip_end < seg_end: + clip_end = seg_end + return round(clip_start, 2), round(clip_end, 2) @@ -389,7 +438,15 @@ def main() -> None: if not highlights: die("No suitable highlights found") - print(f"[info] Selected {len(highlights)} highlights") + print(f"[info] Selected {len(highlights)} candidate highlights") + + # Merge nearby highlights into longer clips (no duration limit) + pre_merge_count = len(highlights) + highlights = merge_nearby_highlights(highlights) + if len(highlights) < pre_merge_count: + print(f"[info] Merged into {len(highlights)} clips ({pre_merge_count - len(highlights)} merges)") + else: + print("[info] No merges needed — highlights are well separated") highlights_info = [] for i, hl in enumerate(highlights, 1): diff --git a/addons/officials/crew/selfmedia-operator/skills/instagram-publish/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/instagram-publish/SKILL.md index 2709e43f..c650ba81 100644 --- a/addons/officials/crew/selfmedia-operator/skills/instagram-publish/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/instagram-publish/SKILL.md @@ -91,24 +91,3 @@ python3 ./skills/instagram-publish/scripts/publish_instagram.py \ | RATE_LIMIT | API 限制 | 等待后重试 | 注意:Instagram API 发布只支持 **URL** 形式的媒体,不支持本地文件上传。需要先将图片/视频上传到可公开访问的 URL。 - ---- - -## 发布记录(强制) - -发布成功后,**必须**立即调用 `published-track` 技能记录发布信息: - -```bash -./skills/published-track/scripts/record.sh \ - --platform instagram \ - --title "标题" \ - --content-type post \ - --source-folder "<原始文件夹路径>" \ - --publish-url "<发布URL>" \ - --publish-date "$(date +%Y-%m-%d)" -``` - -`--source-folder` 为原始内容所在的相对路径(如 `output_articles/xxx` 或 `output_videos/xxx`)。 -`--publish-url` 为发布后获得的 URL,若发布失败则留空并在 `--notes` 中注明原因。 - -执行 `./skills/published-track/scripts/init-db.sh`(幂等,重复执行无副作用)。 diff --git a/addons/officials/crew/selfmedia-operator/skills/juejin-publish/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/juejin-publish/SKILL.md index 13aeb5eb..cbc24c19 100644 --- a/addons/officials/crew/selfmedia-operator/skills/juejin-publish/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/juejin-publish/SKILL.md @@ -123,6 +123,14 @@ browser evaluate fn="(() => { const ta = document.querySelector('.CodeMirror tex b. browser upload /tmp/openclaw/uploads/cover.jpg c. 忽略可能的超时提示 + **Patchright 1.60+ 可选方案**:用 `locator.drop()` 拖拽封面图到上传区域,更稳定: + ```javascript + const buf = fs.readFileSync('/tmp/openclaw/uploads/cover.jpg'); + await page.locator('.').drop({ + files: { name: 'cover.jpg', mimeType: 'image/jpeg', buffer: buf } + }); + ``` + 4. 填写摘要(必填 *,仅兜底路径需要手动填): evaluate 找到摘要 textarea 并 fill 摘要内容取文章前 100 字左右的核心描述 @@ -172,22 +180,3 @@ See `references/publish-options.md` for category list, tag suggestions, and cove ❌ 兜底路径忘记填摘要(*必填): → 发布按钮无响应,因为摘要为空 ``` - -## 发布记录(强制) - -发布成功后,**必须**立即调用 `published-track` 技能记录发布信息: - -```bash -./skills/published-track/scripts/record.sh \ - --platform juejin \ - --title "标题" \ - --content-type article \ - --source-folder "<原始文件夹路径>" \ - --publish-url "<发布URL>" \ - --publish-date "$(date +%Y-%m-%d)" -``` - -`--source-folder` 为原始内容所在的相对路径(如 `output_articles/xxx` 或 `output_videos/xxx`)。 -`--publish-url` 为发布后获得的 URL,若发布失败则留空并在 `--notes` 中注明原因。 - -执行 `./skills/published-track/scripts/init-db.sh`(幂等,重复执行无副作用)。 diff --git a/addons/officials/crew/selfmedia-operator/skills/pinterest-publish/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/pinterest-publish/SKILL.md index f4790fd6..c53e19b6 100644 --- a/addons/officials/crew/selfmedia-operator/skills/pinterest-publish/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/pinterest-publish/SKILL.md @@ -83,24 +83,3 @@ python3 ./skills/pinterest-publish/scripts/publish_pinterest.py \ | INVALID_BOARD | 看板 ID 无效 | 检查看板是否存在 | 注意:Pinterest API 仅支持 URL 形式的媒体,不支持本地文件上传。 - ---- - -## 发布记录(强制) - -发布成功后,**必须**立即调用 `published-track` 技能记录发布信息: - -```bash -./skills/published-track/scripts/record.sh \ - --platform pinterest \ - --title "标题" \ - --content-type post \ - --source-folder "<原始文件夹路径>" \ - --publish-url "<发布URL>" \ - --publish-date "$(date +%Y-%m-%d)" -``` - -`--source-folder` 为原始内容所在的相对路径(如 `output_articles/xxx` 或 `output_videos/xxx`)。 -`--publish-url` 为发布后获得的 URL,若发布失败则留空并在 `--notes` 中注明原因。 - -执行 `./skills/published-track/scripts/init-db.sh`(幂等,重复执行无副作用)。 diff --git a/addons/officials/crew/selfmedia-operator/skills/published-track/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/published-track/SKILL.md index 8e0bcc85..9cf17b78 100644 --- a/addons/officials/crew/selfmedia-operator/skills/published-track/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/published-track/SKILL.md @@ -1,6 +1,6 @@ --- name: published-track -description: 发布记录追踪。使用 SQLite 数据库记录所有平台发布内容及其互动数据,按平台分表管理。发布后必须调用本技能记录,心跳巡检时更新数据。 +description: 发布记录追踪。使用 SQLite 数据库记录所有平台发布内容及其互动数据,按平台分表管理。三大块:与发布技能结合(发布记录 1B;打分评估 1A 由 content-calibrator 负责)、数据更新、查询与平台设置。 metadata: openclaw: emoji: "📊" @@ -12,15 +12,15 @@ metadata: # published-track — 发布记录追踪 -统一管理所有平台(微信公众号、知乎、B站、抖音、快手、小红书、今日头条、掘金、Twitter/X、Facebook、Instagram、TikTok、YouTube、Pinterest、Threads、企业微信朋友圈)的发布记录与互动数据。 +统一管理所有平台(微信公众号、微信视频号、知乎、B站、抖音、快手、小红书、今日头条、掘金、Twitter/X、Facebook、Instagram、TikTok、YouTube、Pinterest、Threads)的发布记录与互动数据。 + +> 企业微信朋友圈不纳入追踪记录(无公开 URL、互动数据无法自动获取、运营复盘价值低),发布后不调 `record.sh`。 --- ## 数据库位置 -`./db/published_track.db`(相对于工作区根目录) - -初始化(幂等,可重复执行): +`./db/published_track.db`(相对于工作区根目录)。初始化(幂等): ```bash ./skills/published-track/scripts/init-db.sh @@ -32,7 +32,8 @@ metadata: | 平台 | 表名 | 内容类型 | 特有指标 | |------|------|---------|---------| -| 微信公众号 | `pub_wx_mp` | article/video/post | reads, shares, favorites, likes, comments | +| 微信公众号 | `pub_wx_mp` | article | reads, shares, favorites, likes, comments | +| 微信视频号 | `pub_wx_channel` | video | plays, likes, comments, shares, favorites | | 知乎 | `pub_zhihu` | article/post | views, upvotes, comments, favorites | | B站 | `pub_bilibili` | video | plays, danmaku, likes, coins, favorites, shares, comments | | 抖音 | `pub_douyin` | video | plays, likes, comments, shares, favorites | @@ -47,96 +48,177 @@ metadata: | YouTube | `pub_youtube` | video | views, likes, comments, shares | | Pinterest | `pub_pinterest` | post | impressions, saves, comments | | Threads | `pub_threads` | post | views, likes, reposts, replies | -| 企业微信朋友圈 | `pub_wxwork_moments` | post | likes, comments | + +`--platform` 取「表名」去掉 `pub_` 前缀,如 `wx_mp`、`wx_channel`、`xhs`、`bilibili`。 --- ## 表结构 -每张表共享以下通用字段: +每张表共享通用字段:`id`(自增主键)、`title`、`content_type`(article/video/post)、`source_folder`(原始文件夹,如 `output_articles/xxx`,**不做唯一约束,同内容可同平台多次发布**)、`publish_url`、`publish_date`(YYYY-MM-DD)、`distribute_status`(0=待分发,1=无需分发,2=已分发)、`notes`、`created_at`、`updated_at`。各平台特有互动指标默认 0,另有 `top_comment`(主要留言摘要)。 -| 字段 | 类型 | 说明 | -|------|------|------| -| id | INTEGER PK | 自增主键 | -| title | TEXT NOT NULL | 标题 | -| content_type | TEXT NOT NULL | article / video / post | -| source_folder | TEXT NOT NULL | 原始文件夹(如 output_articles/xxx 或 output_videos/xxx) | -| publish_url | TEXT | 发布后 URL | -| publish_date | TEXT NOT NULL | 发布日期(YYYY-MM-DD) | -| notes | TEXT | 备注 | -| created_at | TEXT | 创建时间 | -| updated_at | TEXT | 更新时间 | +### content-calibrator 打分字段 -去重键:`source_folder`(同一篇内容在同一平台只记录一次) - -各平台特有的互动指标字段默认值为 0,另有 `top_comment`(主要留言摘要,TEXT)字段。 +| 字段 | 说明 | +|------|------| +| `cal_enabled` | 该记录是否参与 content-calibrator 复盘(0/1) | +| `cal_score_er/hp/sr/ql/na/ab/pv` | 7 维分(0-5):情感共鸣/钩子强度/社会议题/金句密度/叙事性/受众广度/实用价值 | +| `cal_composite` | 综合分(0-10) | +| `cal_rubric_version` | 打分时 rubric 版本 | +| `cal_scored_at` | 打分时间 | --- -## 使用方式 +# 三大使用方式 + +## 块一·与发布技能结合 + +本块描述发布记录脚本的用法与编排意图。**实际编排由 `AGENTS.md`("按需写作 / 发布记录管理与复盘")与执行流类技能(`gaoqian-article`、`video-product`)承担**;各发布技能本身只管发布,不提及打分与记录。流程顺序为 **打分 → 发布 → 记录**。 -### 1. 发布后立即记录(强制) +### 流程 1A·打分评估(发布前自检) -每完成一个平台的发布,**必须**立即调用 `record.sh` 记录: +**打分由 `content-calibrator` 技能负责**(blind sub-agent + `score-only.sh` + 阈值门 + 最多 2 轮改稿重打 + 平台未启用跳过;视频内容打分锚在脚本定稿前)。完整流程见 `content-calibrator/SKILL.md` 的"流程 1A·打分评估(发布前自检)",本技能不重复描述。 + +### 流程 1B·发布记录(发布后) + +发布成功后调用合并入口 `record.sh`: + +- **提供 `--cal-*` 分数** → 自动置 `cal_enabled=1` + 算 composite + 记 `cal_rubric_version`(从 `.cheat-state.json` 读) +- **不提供分数** → `cal_enabled=0`(不参与复盘) +- 显式 `--cal-enabled 0/1` → 覆盖自动判定 ```bash +# 正常发布后(1A 已打过分的平台,把分一并传入) +./skills/published-track/scripts/record.sh \ + --platform wx_mp \ + --title "标题" \ + --content-type article \ + --source-folder "output_articles/xxx" \ + --publish-url "https://mp.weixin.qq.com/s/xxx" \ + --cal-er 3 --cal-hp 4 --cal-sr 3 --cal-ql 4 --cal-na 3 --cal-ab 4 --cal-pv 2 + +# 平台未启用 calibration,或补发/补登记历史作品(不打分) ./skills/published-track/scripts/record.sh \ - --platform \ + --platform xhs \ --title "标题" \ - --content-type \ + --content-type post \ --source-folder "output_articles/xxx" \ - --publish-url "https://..." \ - --publish-date "$(date +%Y-%m-%d)" \ - [--notes "备注"] + --publish-url "https://www.xiaohongshu.com/xxx" ``` -`--platform` 值对应上表「表名」去掉 `pub_` 前缀,如 `wx_mp`、`zhihu`、`bilibili` 等。 +参数说明: +- `--distribute-status`:0=待分发(默认),1=无需分发,2=已分发。 +- `--publish-date`:**省略即默认当日**。❌ 勿传 `"$(date +%Y-%m-%d)"`(exec 沙箱不展开 `$()`);仅补登记非当日作品时传字面量如 `2026-06-14`。 +- `--publish-url`:发布失败时留空并在 `--notes` 注明原因。 +- `score-and-record.sh` 已合并为 `record.sh` 的薄 wrapper,兼容保留,新调用直接用 `record.sh`。 + +> **设计依据**:有分数说明 1A 已通过、`calibration//` 必存在;无分则 1A 阶段就该报错了。故"有分即追踪、无分即不追踪"。 -若发布失败(如平台拒绝、超时),仍需记录,`publish_url` 留空,`notes` 中注明失败原因。 +--- + +## 块二·数据更新 -### 2. 数据更新(心跳巡检) +### 流程 2A·自动更新(定时任务用) -使用 `update-metrics.sh` 更新互动数据: +`fetch-and-update-metrics.sh` 封装 login-manager 探活 → API 抓取 → DB 写入,凌晨复盘心跳调用: ```bash -./skills/published-track/scripts/update-metrics.sh \ - --platform \ +# 通过 source-folder 从 DB 查 publish_url → 抓取 → 写入 +./skills/published-track/scripts/fetch-and-update-metrics.sh \ + --platform --source-folder "output_articles/xxx" + +# 按 id 逐条抓(同 folder 多条记录各自独立统计,推荐) +./skills/published-track/scripts/fetch-and-update-metrics.sh \ + --platform xhs --id --xsec-token --xsec-source pc_feed +``` + +返回 JSON 统一格式: + +| 场景 | 返回示例 | +|------|---------| +| 脚本获取成功 | `{"ok":true,"method":"script","platform":"bilibili","content_id":"BVxxx","metrics_params":"..."}` | +| Cookie 失效 | `{"ok":false,"error":"SESSION_EXPIRED","platform":"xhs","method":"script","hint":"..."}` | +| 需浏览器获取 | `{"ok":false,"method":"browser","platform":"twitter","hint":"使用 twitter-interact 技能..."}` | +| 需手动提供 | `{"ok":false,"method":"manual","platform":"wx_mp","hint":"该平台互动数据无法自动获取..."}` | + +Exit codes:0=成功/浏览器/手动(非错误),1=一般错误,2=SESSION_EXPIRED。 + +- **脚本支持**:xhs、bilibili、douyin、kuaishou +- **浏览器获取**:zhihu、toutiao、juejin、twitter、youtube、facebook、instagram、tiktok、pinterest、threads +- **手动提供**:wx_mp、wx_channel + +### 流程 2B·用户提供数据(Agent 补录) + +用户主动告知已发布内容的信息,Agent 用 `record.sh` 录入基础信息,再用 `update-metrics.sh` 补录互动数据: + +```bash +# 1) 录入基础信息 +./skills/published-track/scripts/record.sh \ + --platform wx_mp --title "用户提供的标题" --content-type article \ --source-folder "output_articles/xxx" \ - --reads 1234 \ - --likes 56 \ - ... + --publish-url "https://mp.weixin.qq.com/s/xxx" \ + --publish-date "2026-06-14" --distribute-status 1 --notes "用户手动录入" + +# 2) 补录互动数据(只传用户提供的字段,其余保持不变) +./skills/published-track/scripts/update-metrics.sh \ + --platform wx_mp --source-folder "output_articles/xxx" \ + --reads 1234 --likes 56 --shares 12 ``` -只需传入要更新的指标字段,未传入的字段保持不变。 +各平台可传指标字段见上方「平台与表对应关系」"特有指标"列。 + +--- + +## 块三·查询与平台设置 -### 3. 查询 +### 流程 3A·查询待分发内容(白天 heartbeat 用) ```bash -# 查询某平台全部记录 -./skills/published-track/scripts/query.sh --platform zhihu +./skills/published-track/scripts/query-pending.sh # 所有平台待分发 +./skills/published-track/scripts/query-pending.sh --platform wx_mp # 单平台 +``` -# 查询某平台最近 N 条 -./skills/published-track/scripts/query.sh --platform zhihu --limit 10 +返回 JSON 数组,每项含 `platform`、`source_folder`、`title`、`publish_url`。 -# 查询特定内容是否已发布到某平台 -./skills/published-track/scripts/check-published.sh \ - --platform zhihu \ - --source-folder "output_articles/xxx" +### 流程 3B·设置 + +**分发状态**: + +```bash +./skills/published-track/scripts/set-distribute-status.sh \ + --platform wx_mp --source-folder "output_articles/xxx" --status 2 +./skills/published-track/scripts/set-distribute-status.sh \ + --platform wx_mp --id 3 --status 2 +./skills/published-track/scripts/set-distribute-status.sh \ + --platform wx_mp --mark-all-distributed +``` + +**平台打分开关 + 阈值**: -# 查询所有平台中未发布的原始文件夹 -./skills/published-track/scripts/query.sh --unpublished +```bash +./skills/content-calibrator/scripts/cal-toggle.sh --list # 全平台开关+阈值 +./skills/content-calibrator/scripts/cal-toggle.sh --platform wx_mp --status # 单平台开关 +./skills/content-calibrator/scripts/cal-toggle.sh --platform wx_mp --enable # 启用 +./skills/content-calibrator/scripts/cal-toggle.sh --platform wx_mp --disable # 停用(需确认) +./skills/content-calibrator/scripts/cal-toggle.sh --platform wx_mp --threshold # 查看阈值 +./skills/content-calibrator/scripts/cal-toggle.sh --platform wx_mp --set-threshold 2 # 设阈值 ``` -### 4. 清理低数据记录 +阈值语义:每维 0-5,需 **> 阈值**才放行发布;阈值 0 = 不拦截(起步默认)。Agent 不得自动启用某平台打分,必须告知用户由用户决定。阈值可由 Agent 在 content-calibrator 复盘后根据累积数据推荐并经用户确认后设置(见 `content-calibrator/SKILL.md` 复盘段)。 -发布超过 7 天、互动指标低于 300 的记录,使用 `query.sh` 查出后可删除: +### 流程 3C·通用查询(Agent 按需调用) ```bash -./skills/published-track/scripts/query.sh --platform zhihu --stale-days 7 --below 300 +./skills/published-track/scripts/query.sh --platform zhihu # 某平台全部记录 +./skills/published-track/scripts/query.sh --platform zhihu --limit 10 # 最近 N 条 +./skills/published-track/scripts/check-published.sh \ + --platform zhihu --source-folder "output_articles/xxx" # 是否已发布 ``` --- ## 与发布技能的配合 -所有发布技能(wx-mp-publisher、sync-from-mp、bilibili-publish 等)在发布成功后必须调用 `record.sh` 记录。各技能 SKILL.md 中已标注此要求,主 agent 无需额外提醒。 +所有发布技能(wx-mp-publisher、xhs-publish、gaoqian-article、wechat-channels-publish、bilibili-publish 等)的流程统一为 **打分(1A) → 发布 → 记录(1B)**。各技能 SKILL.md 的"打分评估 / 发布记录"段标注此要求,主 agent 无需额外提醒。 + +**平台代号对照**:`wx-mp-publisher`/`sync-from-mp` → `wx_mp`;`wechat-channels-publish` → `wx_channel`;`xhs-publish` → `xhs`。 diff --git a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/check-published.sh b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/check-published.sh index b181fdca..cdd9edf5 100755 --- a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/check-published.sh +++ b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/check-published.sh @@ -4,6 +4,19 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" DB="$ROOT/db/published_track.db" +# Self-heal stale schema: if a platform table is missing, run idempotent init-db.sh +# (CREATE TABLE IF NOT EXISTS) and re-check before treating the platform as unknown. +# Auto-adds tables for platforms introduced into init-db.sh after the DB was first created. +ensure_platform_table() { + local table="pub_$1" found + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + if [ -z "$found" ]; then + bash "$(dirname "$0")/init-db.sh" >/dev/null 2>&1 || true + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + fi + [ -n "$found" ] +} + if [ ! -f "$DB" ]; then echo '{"exists":false}' exit 0 @@ -25,8 +38,7 @@ if [ -z "$PLATFORM" ] || [ -z "$SOURCE_FOLDER" ]; then fi TABLE="pub_${PLATFORM}" -VALID=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$TABLE';") -if [ -z "$VALID" ]; then +if ! ensure_platform_table "$PLATFORM"; then echo "{\"ok\":false,\"error\":\"unknown platform: $PLATFORM\"}" exit 1 fi diff --git a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/init-db.sh b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/init-db.sh index 096badda..2dded62b 100755 --- a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/init-db.sh +++ b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/init-db.sh @@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS pub_wx_mp ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, reads INTEGER DEFAULT 0, shares INTEGER DEFAULT 0, favorites INTEGER DEFAULT 0, @@ -23,6 +24,17 @@ CREATE TABLE IF NOT EXISTS pub_wx_mp ( comments INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -32,15 +44,27 @@ CREATE TABLE IF NOT EXISTS pub_zhihu ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, views INTEGER DEFAULT 0, upvotes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, favorites INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -50,9 +74,10 @@ CREATE TABLE IF NOT EXISTS pub_bilibili ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, plays INTEGER DEFAULT 0, danmaku INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, @@ -62,6 +87,17 @@ CREATE TABLE IF NOT EXISTS pub_bilibili ( comments INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -71,9 +107,10 @@ CREATE TABLE IF NOT EXISTS pub_douyin ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, plays INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, @@ -81,6 +118,17 @@ CREATE TABLE IF NOT EXISTS pub_douyin ( favorites INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -90,15 +138,27 @@ CREATE TABLE IF NOT EXISTS pub_kuaishou ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, plays INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, shares INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -108,9 +168,10 @@ CREATE TABLE IF NOT EXISTS pub_xhs ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, views INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, favorites INTEGER DEFAULT 0, @@ -118,6 +179,17 @@ CREATE TABLE IF NOT EXISTS pub_xhs ( shares INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -127,15 +199,27 @@ CREATE TABLE IF NOT EXISTS pub_toutiao ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, impressions INTEGER DEFAULT 0, reads INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -145,15 +229,27 @@ CREATE TABLE IF NOT EXISTS pub_juejin ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, views INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, favorites INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -163,15 +259,27 @@ CREATE TABLE IF NOT EXISTS pub_twitter ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, views INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, retweets INTEGER DEFAULT 0, replies INTEGER DEFAULT 0, bookmarks INTEGER DEFAULT 0, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -181,14 +289,26 @@ CREATE TABLE IF NOT EXISTS pub_facebook ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, reach INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, shares INTEGER DEFAULT 0, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -198,15 +318,27 @@ CREATE TABLE IF NOT EXISTS pub_instagram ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, reach INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, shares INTEGER DEFAULT 0, saves INTEGER DEFAULT 0, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -216,9 +348,10 @@ CREATE TABLE IF NOT EXISTS pub_tiktok ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, plays INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, @@ -226,6 +359,17 @@ CREATE TABLE IF NOT EXISTS pub_tiktok ( favorites INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -235,14 +379,26 @@ CREATE TABLE IF NOT EXISTS pub_youtube ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, views INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, shares INTEGER DEFAULT 0, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -252,13 +408,25 @@ CREATE TABLE IF NOT EXISTS pub_pinterest ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, impressions INTEGER DEFAULT 0, saves INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); @@ -268,34 +436,61 @@ CREATE TABLE IF NOT EXISTS pub_threads ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, views INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, reposts INTEGER DEFAULT 0, replies INTEGER DEFAULT 0, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); --- 企业微信朋友圈 -CREATE TABLE IF NOT EXISTS pub_wxwork_moments ( +-- 微信视频号 +CREATE TABLE IF NOT EXISTS pub_wx_channel ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content_type TEXT NOT NULL CHECK(content_type IN ('article','video','post')), - source_folder TEXT NOT NULL UNIQUE, + source_folder TEXT NOT NULL, publish_url TEXT, publish_date TEXT NOT NULL, + distribute_status INTEGER NOT NULL DEFAULT 0, + plays INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, + shares INTEGER DEFAULT 0, + favorites INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, + cal_enabled INTEGER DEFAULT 0, + cal_score_er INTEGER, + cal_score_hp INTEGER, + cal_score_sr INTEGER, + cal_score_ql INTEGER, + cal_score_na INTEGER, + cal_score_ab INTEGER, + cal_score_pv INTEGER, + cal_composite REAL, + cal_rubric_version TEXT, + cal_scored_at TEXT, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')), updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime')) ); SQL -echo '{"ok":true,"message":"published_track.db initialized"}' +echo '{"ok":true,"message":"published_track.db initialized (with cal_ score columns)"}' diff --git a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/query.sh b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/query.sh index 9358e236..95008862 100755 --- a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/query.sh +++ b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/query.sh @@ -4,6 +4,19 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" DB="$ROOT/db/published_track.db" +# Self-heal stale schema: if a platform table is missing, run idempotent init-db.sh +# (CREATE TABLE IF NOT EXISTS) and re-check before treating the platform as unknown. +# Auto-adds tables for platforms introduced into init-db.sh after the DB was first created. +ensure_platform_table() { + local table="pub_$1" found + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + if [ -z "$found" ]; then + bash "$(dirname "$0")/init-db.sh" >/dev/null 2>&1 || true + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + fi + [ -n "$found" ] +} + if [ ! -f "$DB" ]; then echo '[]' exit 0 @@ -54,8 +67,7 @@ if [ -z "$PLATFORM" ]; then fi TABLE="pub_${PLATFORM}" -VALID=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$TABLE';") -if [ -z "$VALID" ]; then +if ! ensure_platform_table "$PLATFORM"; then echo "{\"ok\":false,\"error\":\"unknown platform: $PLATFORM\"}" exit 1 fi diff --git a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/record.sh b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/record.sh index ae667049..103c4d1d 100755 --- a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/record.sh +++ b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/record.sh @@ -1,66 +1,191 @@ #!/usr/bin/env bash +# record.sh — 发布记录统一入口(已合并 score-and-record.sh) +# +# 行为: +# - 提供任意 --cal-* 分数 → 自动置 cal_enabled=1,算 composite,记 rubric_version +# - 不提供分数 → cal_enabled=0(不参与复盘) +# - 显式 --cal-enabled 0/1 → 覆盖上述自动判定 +# +# 打分由 Agent(blind sub-agent)对照 rubric 完成后,把 7 维分随本调用传入; +# 本脚本只做范围校验、composite 计算、DB 写入。 set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +CAL_ROOT="$ROOT/calibration" DB="$ROOT/db/published_track.db" -# Ensure db exists +# Self-heal stale schema: if a platform table is missing, run idempotent init-db.sh +ensure_platform_table() { + local table="pub_$1" found + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + if [ -z "$found" ]; then + bash "$(dirname "$0")/init-db.sh" >/dev/null 2>&1 || true + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + fi + [ -n "$found" ] +} + if [ ! -f "$DB" ]; then bash "$(dirname "$0")/init-db.sh" fi # Parse args PLATFORM="" TITLE="" CONTENT_TYPE="" SOURCE_FOLDER="" PUBLISH_URL="" PUBLISH_DATE="" NOTES="" +DISTRIBUTE_STATUS="" +CAL_ENABLED_EXPLICIT="" # 仅当用户显式传 --cal-enabled 时非空 +CAL_ER="" CAL_HP="" CAL_SR="" CAL_QL="" CAL_NA="" CAL_AB="" CAL_PV="" CAL_COMPOSITE="" CAL_RUBRIC_VERSION="" while [[ $# -gt 0 ]]; do case "$1" in - --platform) PLATFORM="$2"; shift 2 ;; - --title) TITLE="$2"; shift 2 ;; - --content-type) CONTENT_TYPE="$2"; shift 2 ;; - --source-folder) SOURCE_FOLDER="$2"; shift 2 ;; - --publish-url) PUBLISH_URL="$2"; shift 2 ;; - --publish-date) PUBLISH_DATE="$2"; shift 2 ;; - --notes) NOTES="$2"; shift 2 ;; + --platform) PLATFORM="$2"; shift 2 ;; + --title) TITLE="$2"; shift 2 ;; + --content-type) CONTENT_TYPE="$2"; shift 2 ;; + --source-folder) SOURCE_FOLDER="$2"; shift 2 ;; + --publish-url) PUBLISH_URL="$2"; shift 2 ;; + # ⚠️ 发布日期就是当天时不要传此参数,让脚本默认今天。 + # ❌ 不要用 --publish-date "$(date +%Y-%m-%d)" —— exec 沙箱不展开 $()。 + --publish-date) PUBLISH_DATE="$2"; shift 2 ;; + --notes) NOTES="$2"; shift 2 ;; + --distribute-status) DISTRIBUTE_STATUS="$2"; shift 2 ;; + --cal-enabled) CAL_ENABLED_EXPLICIT="$2"; shift 2 ;; + --cal-er) CAL_ER="$2"; shift 2 ;; + --cal-hp) CAL_HP="$2"; shift 2 ;; + --cal-sr) CAL_SR="$2"; shift 2 ;; + --cal-ql) CAL_QL="$2"; shift 2 ;; + --cal-na) CAL_NA="$2"; shift 2 ;; + --cal-ab) CAL_AB="$2"; shift 2 ;; + --cal-pv) CAL_PV="$2"; shift 2 ;; + --cal-composite) CAL_COMPOSITE="$2"; shift 2 ;; + --cal-rubric-version) CAL_RUBRIC_VERSION="$2"; shift 2 ;; *) echo "{\"ok\":false,\"error\":\"unknown arg: $1\"}"; exit 1 ;; esac done -# Validate required args -if [ -z "$PLATFORM" ] || [ -z "$TITLE" ] || [ -z "$CONTENT_TYPE" ] || [ -z "$SOURCE_FOLDER" ] || [ -z "$PUBLISH_DATE" ]; then - echo '{"ok":false,"error":"missing required args: --platform, --title, --content-type, --source-folder, --publish-date"}' +# Default publish_date to today(防御 exec 沙箱不展开 $() 的脏数据) +if [ -z "$PUBLISH_DATE" ]; then + PUBLISH_DATE="$(date +%Y-%m-%d)" +elif [[ "$PUBLISH_DATE" =~ ^\$\(*date* || "$PUBLISH_DATE" =~ ^\`*date* ]]; then + echo "{\"ok\":false,\"error\":\"--publish-date looks unexpanded: '$PUBLISH_DATE'. omit --publish-date for today, or pass literal like 2026-06-14.\"}" >&2 + PUBLISH_DATE="$(date +%Y-%m-%d)" +fi + +if [ -z "$PLATFORM" ] || [ -z "$TITLE" ] || [ -z "$CONTENT_TYPE" ] || [ -z "$SOURCE_FOLDER" ]; then + echo '{"ok":false,"error":"missing required args: --platform, --title, --content-type, --source-folder"}' exit 1 fi -# Validate platform TABLE="pub_${PLATFORM}" -VALID=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$TABLE';") -if [ -z "$VALID" ]; then +if ! ensure_platform_table "$PLATFORM"; then echo "{\"ok\":false,\"error\":\"unknown platform: $PLATFORM (table $TABLE not found)\"}" exit 1 fi -# Validate content_type case "$CONTENT_TYPE" in article|video|post) ;; *) echo "{\"ok\":false,\"error\":\"invalid content_type: $CONTENT_TYPE (must be article/video/post)\"}"; exit 1 ;; esac -# Check duplicate -EXISTS=$(sqlite3 "$DB" "SELECT COUNT(*) FROM $TABLE WHERE source_folder='${SOURCE_FOLDER//\'/\'\'}';") -if [ "$EXISTS" -gt 0 ]; then - # Update existing record - ESC_URL="${PUBLISH_URL//\'/\'\'}" - ESC_NOTES="${NOTES//\'/\'\'}" - sqlite3 "$DB" "UPDATE $TABLE SET publish_url='$ESC_URL', notes='$ESC_NOTES', updated_at=strftime('%Y-%m-%d %H:%M:%S','now','localtime') WHERE source_folder='${SOURCE_FOLDER//\'/\'\'}';" - ID=$(sqlite3 "$DB" "SELECT id FROM $TABLE WHERE source_folder='${SOURCE_FOLDER//\'/\'\'}';") - echo "{\"ok\":true,\"action\":\"updated\",\"id\":$ID,\"table\":\"$TABLE\"}" +# ── 是否提供了打分 ── +HAS_SCORES=0 +for dim in ER HP SR QL NA AB PV; do + var_name="CAL_$dim" + if [[ -n "${!var_name}" ]]; then HAS_SCORES=1; break; fi +done + +# ── 判定 cal_enabled ── +if [[ -n "$CAL_ENABLED_EXPLICIT" ]]; then + CAL_ENABLED="$CAL_ENABLED_EXPLICIT" +else + if [[ "$HAS_SCORES" -eq 1 ]]; then CAL_ENABLED=1; else CAL_ENABLED=0; fi +fi + +# ── 有分数时:校验范围、算 composite、补 rubric_version ── +if [[ "$HAS_SCORES" -eq 1 ]]; then + for dim in ER HP SR QL NA AB PV; do + var_name="CAL_$dim"; val="${!var_name}" + if [[ -n "$val" ]]; then + if [[ "$val" -lt 0 || "$val" -gt 5 ]] 2>/dev/null; then + echo "{\"ok\":false,\"error\":\"cal_score_$dim=$val out of range (must be 0-5 integer)\"}"; exit 1 + fi + fi + done + + if [[ -z "$CAL_COMPOSITE" ]]; then + er="${CAL_ER:-0}" hp="${CAL_HP:-0}" sr="${CAL_SR:-0}" + ql="${CAL_QL:-0}" na="${CAL_NA:-0}" ab="${CAL_AB:-0}" pv="${CAL_PV:-0}" + CAL_COMPOSITE=$(python3 -c " +er=$er; hp=$hp; sr=$sr; ql=$ql; na=$na; ab=$ab; pv=$pv +print(f'{(er*1.5 + hp*1.5 + sr*1.5 + ql + na + ab + pv) / 8.5 * 2.0:.2f}') +") + fi + + if [[ -z "$CAL_RUBRIC_VERSION" ]]; then + CAL_DIR="$CAL_ROOT/$PLATFORM" + if [[ -d "$CAL_DIR" && -f "$CAL_DIR/.cheat-state.json" ]]; then + CAL_RUBRIC_VERSION=$(python3 -c "import json; print(json.load(open('$CAL_DIR/.cheat-state.json')).get('rubric_version','v0'))" 2>/dev/null || echo "v0") + else + CAL_RUBRIC_VERSION="v0" + fi + fi + + echo "📊 打分 — $PLATFORM ER=${CAL_ER:-0} HP=${CAL_HP:-0} SR=${CAL_SR:-0} QL=${CAL_QL:-0} NA=${CAL_NA:-0} AB=${CAL_AB:-0} PV=${CAL_PV:-0} composite=$CAL_COMPOSITE (rubric $CAL_RUBRIC_VERSION)" >&2 +fi + +# ── 构建 cal_ 列 ── +cal_cols=""; cal_vals="" + +if [[ -n "$CAL_ENABLED" ]]; then + cal_cols="cal_enabled"; cal_vals="$CAL_ENABLED" +fi + +for dim in er hp sr ql na ab pv; do + var_name="CAL_$(echo $dim | tr '[:lower:]' '[:upper:]')"; val="${!var_name}" + if [[ -n "$val" ]]; then + if [[ -n "$cal_cols" ]]; then cal_cols="$cal_cols,cal_score_$dim"; cal_vals="$cal_vals,$val" + else cal_cols="cal_score_$dim"; cal_vals="$val"; fi + fi +done + +if [[ -n "$CAL_COMPOSITE" ]]; then + if [[ -n "$cal_cols" ]]; then cal_cols="$cal_cols,cal_composite"; cal_vals="$cal_vals,$CAL_COMPOSITE" + else cal_cols="cal_composite"; cal_vals="$CAL_COMPOSITE"; fi +fi + +if [[ -n "$CAL_RUBRIC_VERSION" ]]; then + esc_rv="${CAL_RUBRIC_VERSION//\'/\'\'}" + if [[ -n "$cal_cols" ]]; then cal_cols="$cal_cols,cal_rubric_version"; cal_vals="$cal_vals,'$esc_rv'" + else cal_cols="cal_rubric_version"; cal_vals="'$esc_rv'"; fi +fi + +if [[ -n "$cal_cols" ]]; then + cal_cols="$cal_cols,cal_scored_at" + scored_at="$(strftime '%Y-%m-%d %H:%M:%S' 2>/dev/null || date '+%Y-%m-%d %H:%M:%S')" + cal_vals="$cal_vals,'$scored_at'" +fi + +# ── distribute_status ── +DS_VAL=0 +if [[ -n "$DISTRIBUTE_STATUS" ]]; then + case "$DISTRIBUTE_STATUS" in + 0|1|2) DS_VAL="$DISTRIBUTE_STATUS" ;; + *) echo '{"ok":false,"error":"--distribute-status must be 0(pending), 1(no_distribution), or 2(distributed)"}'; exit 1 ;; + esac +fi + +ESC_TITLE="${TITLE//\'/\'\'}" +ESC_FOLDER="${SOURCE_FOLDER//\'/\'\'}" +ESC_URL="${PUBLISH_URL//\'/\'\'}" +ESC_NOTES="${NOTES//\'/\'\'}" + +BASE_COLS="title,content_type,source_folder,publish_url,publish_date,distribute_status,notes" +BASE_VALS="'$ESC_TITLE','$CONTENT_TYPE','$ESC_FOLDER','$ESC_URL','$PUBLISH_DATE',$DS_VAL,'$ESC_NOTES'" + +if [[ -n "$cal_cols" ]]; then + ALL_COLS="$BASE_COLS,$cal_cols"; ALL_VALS="$BASE_VALS,$cal_vals" else - # Insert new record - ESC_TITLE="${TITLE//\'/\'\'}" - ESC_FOLDER="${SOURCE_FOLDER//\'/\'\'}" - ESC_URL="${PUBLISH_URL//\'/\'\'}" - ESC_NOTES="${NOTES//\'/\'\'}" - sqlite3 "$DB" "INSERT INTO $TABLE (title,content_type,source_folder,publish_url,publish_date,notes) VALUES ('$ESC_TITLE','$CONTENT_TYPE','$ESC_FOLDER','$ESC_URL','$PUBLISH_DATE','$ESC_NOTES');" - ID=$(sqlite3 "$DB" "SELECT last_insert_rowid();") - echo "{\"ok\":true,\"action\":\"inserted\",\"id\":$ID,\"table\":\"$TABLE\"}" + ALL_COLS="$BASE_COLS"; ALL_VALS="$BASE_VALS" fi + +sqlite3 "$DB" "INSERT INTO $TABLE ($ALL_COLS) VALUES ($ALL_VALS);" +ID=$(sqlite3 "$DB" "SELECT last_insert_rowid();") +echo "{\"ok\":true,\"action\":\"inserted\",\"id\":$ID,\"table\":\"$TABLE\",\"distribute_status\":$DS_VAL,\"cal_enabled\":${CAL_ENABLED:-0}}" diff --git a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/update-metrics.sh b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/update-metrics.sh index e586f070..716fdfbc 100755 --- a/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/update-metrics.sh +++ b/addons/officials/crew/selfmedia-operator/skills/published-track/scripts/update-metrics.sh @@ -4,21 +4,70 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" DB="$ROOT/db/published_track.db" +# Self-heal stale schema: if a platform table is missing, run idempotent init-db.sh +# (CREATE TABLE IF NOT EXISTS) and re-check before treating the platform as unknown. +# Auto-adds tables for platforms introduced into init-db.sh after the DB was first created. +ensure_platform_table() { + local table="pub_$1" found + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + if [ -z "$found" ]; then + bash "$(dirname "$0")/init-db.sh" >/dev/null 2>&1 || true + found=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") + fi + [ -n "$found" ] +} + +# --help/-h is a usage probe; honor it before the DB check so it works without a DB. +for arg in "$@"; do + if [ "$arg" = "--help" ] || [ "$arg" = "-h" ]; then + cat <<'EOF' +Usage: update-metrics.sh --platform (--id | --source-folder ) [-- ]... + +Update metric columns of an existing published-track record in table pub_. + +Required: + --platform Platform table suffix (record lives in pub_). + --id Update ONE row by primary key id (preferred — avoids + same-source_folder duplicate-publish rows being written + together). Either --id or --source-folder is required. + --source-folder Update ALL rows matching source_folder (legacy batch + write; use only when you intentionally want every row + with that folder to receive the same metrics). + +Metrics (at least one required): + -- A metric column to set (integer or text). + --= Equivalent inline form. + Valid columns depend on the platform table schema; an unknown column is rejected + with the list of valid metric columns. + +Examples: + update-metrics.sh --platform xhs --id 10 --views 100 --likes 10 + update-metrics.sh --platform xhs --source-folder abc --views 100 --likes 10 + update-metrics.sh --platform wx --source-folder abc --reads=50 + +Output: JSON on stdout. {"ok":true,...} on success, {"ok":false,"error":...} on error. +EOF + exit 0 + fi +done + if [ ! -f "$DB" ]; then echo '{"ok":false,"error":"database not initialized, run init-db.sh first"}' exit 1 fi # Parse args -PLATFORM="" SOURCE_FOLDER="" +PLATFORM="" SOURCE_FOLDER="" ROW_ID="" declare -A METRICS while [[ $# -gt 0 ]]; do case "$1" in --platform) PLATFORM="$2"; shift 2 ;; --source-folder) SOURCE_FOLDER="$2"; shift 2 ;; + --id) ROW_ID="$2"; shift 2 ;; --*=*) KEY="${1#--}" + KEY="${KEY%%=*}" VAL="${1#*=}" METRICS["$KEY"]="$VAL" shift @@ -33,22 +82,38 @@ while [[ $# -gt 0 ]]; do esac done -if [ -z "$PLATFORM" ] || [ -z "$SOURCE_FOLDER" ]; then - echo '{"ok":false,"error":"missing required args: --platform, --source-folder"}' +if [ -z "$PLATFORM" ]; then + echo '{"ok":false,"error":"missing required arg: --platform"}' + exit 1 +fi + +# --id 优先(按主键写单行,避免同 source_folder 多条重复发布被批量污染); +# 否则回退到 --source-folder(批量写所有同 folder 行,旧行为)。 +if [ -n "$ROW_ID" ]; then + if ! [[ "$ROW_ID" =~ ^[0-9]+$ ]]; then + echo "{\"ok\":false,\"error\":\"--id must be a positive integer, got: $ROW_ID\"}" + exit 1 + fi + WHERE_CLAUSE="id=${ROW_ID}" + LOCATE_KEY="id=${ROW_ID}" +elif [ -n "$SOURCE_FOLDER" ]; then + WHERE_CLAUSE="source_folder='${SOURCE_FOLDER//\'/\'\'}'" + LOCATE_KEY="source_folder=$SOURCE_FOLDER" +else + echo '{"ok":false,"error":"missing required arg: --id or --source-folder"}' exit 1 fi TABLE="pub_${PLATFORM}" -VALID=$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$TABLE';") -if [ -z "$VALID" ]; then +if ! ensure_platform_table "$PLATFORM"; then echo "{\"ok\":false,\"error\":\"unknown platform: $PLATFORM\"}" exit 1 fi # Check record exists -EXISTS=$(sqlite3 "$DB" "SELECT COUNT(*) FROM $TABLE WHERE source_folder='${SOURCE_FOLDER//\'/\'\'}';") +EXISTS=$(sqlite3 "$DB" "SELECT COUNT(*) FROM $TABLE WHERE $WHERE_CLAUSE;") if [ "$EXISTS" -eq 0 ]; then - echo "{\"ok\":false,\"error\":\"no record found in $TABLE for source_folder=$SOURCE_FOLDER\"}" + echo "{\"ok\":false,\"error\":\"no record found in $TABLE for ${LOCATE_KEY}\"}" exit 1 fi @@ -79,6 +144,6 @@ SET_PARTS+=("updated_at=strftime('%Y-%m-%d %H:%M:%S','now','localtime')") SET_CLAUSE=$(IFS=','; echo "${SET_PARTS[*]}") -sqlite3 "$DB" "UPDATE $TABLE SET $SET_CLAUSE WHERE source_folder='${SOURCE_FOLDER//\'/\'\'}';" +sqlite3 "$DB" "UPDATE $TABLE SET $SET_CLAUSE WHERE $WHERE_CLAUSE;" -echo "{\"ok\":true,\"table\":\"$TABLE\",\"source_folder\":\"$SOURCE_FOLDER\",\"updated_columns\":${#METRICS[@]}}" +echo "{\"ok\":true,\"table\":\"$TABLE\",\"located_by\":\"${LOCATE_KEY}\",\"updated_columns\":${#METRICS[@]}}" diff --git a/addons/officials/crew/selfmedia-operator/skills/t2video/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/t2video/SKILL.md deleted file mode 100644 index c356d6c7..00000000 --- a/addons/officials/crew/selfmedia-operator/skills/t2video/SKILL.md +++ /dev/null @@ -1,215 +0,0 @@ ---- -name: t2video -description: 一站式短视频制作工具。整合 SiliconFlow TTS 语音合成、素材搜集(Pexels/Pixabay/AI 生成)和 FFmpeg 组装,从脚本到成品视频一步完成。无 subagent 模式,适合短小视频。 -metadata: - openclaw: - emoji: "🎥" - requires: - bins: - - python3 - - ffmpeg - - ffprobe - env: - - SILICONFLOW_API_KEY - primaryEnv: SILICONFLOW_API_KEY ---- - -# t2video — 一站式短视频制作 - -Use this skill when: -- 需要从脚本生成完整短视频(TTS + 素材 + 组装) -- 用户指定主题和已有素材,生成短视频 - -**本技能是 video-producer 的简化版**:适合短小视频制作,且只生成9:16的竖屏视频。 - -**不烧录字幕**:大部分平台支持自动生成字幕,无需手动烧录。 - ---- - -## ⚙️ 执行方式(强制) - -本技能涉及多步骤生产流程,你应该 self-spawn 一个 subagent 来执行,原因:subagent 独立上下文,不会因对话历史积累而降低输出质量。 - -你只负责跟进subagent的执行,避免它们长时间卡在某个步骤,必要时可以提供提示或调整执行策略。另外在关键节点要求它向你汇报,你检查后再让它继续执行下一步。 - ---- - -## 工作流程 - -### Step 1 — 工作区目录准备 - -在 `output_videos/` 下创建项目文件夹,如 `output_videos//`,作为 project-dir。 - -工作区结构: - -``` -/ -├── script.md # 三段式脚本 -├── tts_requirement.md # 配音需求(由 Step 3 创建) -├── artifacts/ # 产出素材(TTS音频+视频素材) -│ ├── speech.mp3 # TTS 配音 -│ ├── speech.json # TTS 元数据(含 duration) -│ └──

+ + +
+

PLACEHOLDER_TITLE

+

PLACEHOLDER_SUBTITLE

+
+
+ + +``` + +### 1.3 统一后的 content-graph + +**所有节点都是 HTML 帧**,不再有 dynamic/stock 两条路径: + +| 节点类型 | 模板 | 变量来源 | +|---------|------|---------| +| 标题动画 | `glitch-title-916` | agent 填充文案 | +| 文字动效 | `kinetic-type-916` | agent 填充文案 | +| 数据图表 | `data-chart-916` | agent 填充数据 | +| **素材片段** | **`video-clip-916`** | **video_generate / siliconflow-video-gen / pixabay / pexels → MP4 路径** | +| **用户提供片段** | **`video-clip-916`** | **用户提供的 MP4 路径** | +| **AI 生成片段** | **`video-clip-916`** | **siliconflow-video-gen 产出的 MP4 路径** | +| 结尾 CTA | `logo-outro-916` | agent 填充文案 | + +### 1.4 素材获取流程(统一纳入 html-video) + +``` +content-graph 中 productionMode: "stock" 的节点: + │ + ▼ +① 获取素材 MP4 + 优先级: video_generate → siliconflow-video-gen → pixabay-footage → pexels-footage + 产出: clip.mp4 + │ + ▼ +② 注入 video-clip-916 模板 + PLACEHOLDER_VIDEO_SRC → clip.mp4 路径 + PLACEHOLDER_DURATION → ffprobe 获取时长 + PLACEHOLDER_TITLE / SUBTITLE → 可选文字叠加 + │ + ▼ +③ html-video 正常渲染该帧(Chromium 加载 HTML →