diff --git a/.gitignore b/.gitignore index b5a433f3..5bb25d69 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ __pycache__/ patchright/ patchright-v*/ openclaw/ +tests/ # addon crews copied into crews/ at install time — not tracked .pnpm-store/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dd83f7a4..663666d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +# v5.7.0 (2026-08-31) + +### 专家包(Expert Pack)架构 + +> 解决 `AGENTS.md` 膨胀到 257 行 / 19,094 字符逼近注入上限、平台流程互相干扰、运营风格无法结构化复用的问题。详见 `docs/expert-pack-dna-architecture.md`。 + +- **改造后形态**:一个 crew = 薄 `AGENTS.md`(通用准则 + 专家包路由表)+ 多套专家包(工作流 + 技能组合 + 知识库 + DNA)+ DNA 库 + 共享的 `SOUL` / `TOOLS` / `IDENTITY` / `USER` / `MEMORY`。同一个 crew 保持一个 workspace、一套记忆、一个人格;专家包只是「工作面」,不是子 crew。 +- **基于 OpenClaw Skill 按需加载**:源码验证 bootstrap 文件每轮全量注入、`bootstrap-extra-files` hook 也是全量注入、祖先链加载不会发现任意子目录——均不适合承载专家包。Skill 机制只注入 `name + description + 路径`,agent 任务匹配时才 read 全文,是原生的按需加载通道。 +- **技能收纳规则**:只被一个领域使用的技能整体迁入对应专家包 `tools/`,从路由面消失;跨领域复用的保持顶层。收纳后技能总数不增反降,路由更准。技术依据:workspace 扫描器发现 `SKILL.md` 后停止深入,包内 `tools/*/SKILL.md` 不会被注册为独立技能。 +- **薄 `AGENTS.md` 职责边界**:只保留通用准则(品牌红线、素材治理)、专家包路由表(任务特征 → 专家包名)、兜底规则。各平台具体流程全部下沉到对应专家包。 +- **专家包内六大 workflow**:`style-dna`(DNA 创建与更新)、`content-production`(内容生产)、`account-setup`(起号与定位)、`account-benchmark`(对标比较)、`editing`(改稿与调整)、`review`(数据复盘)。workflow 每一步必须能落到工具或明确标注为 agent 推理任务,严禁"专家进行 XX"空话。 +- **首个改造对象**:`crews/main`(小贝),已完成 `expert-wx-mp` 专家包。 + +### DNA(内容风格 DNA) + +- **三层产物**:单篇文章 → DNA report(单篇定性提取)→ DNA 文档(聚合同一 DNA 目录下全部 report 的生产规则)→ DNA template(写稿时直接执行的结构化要求)。 +- **存储结构**:运行时保存在平台运营文件夹下,按平台和 DNA ID 分层——`/dna//reports/`、`covers/`、`{dna-id}.dna.md`、`{dna-id}.template.md`。`dna/` 与 `calibration/` 由集中目录下沉进各平台运营文件夹。 +- **数据直连 DNA(废除 rubric)**:`pub_*` 表新增 `dna_id` / `account` / `perf_evaluated` 列,作品与 DNA 直接挂钩。评估按量触发(每平台每 DNA 累积 ≥5 条成熟记录),归因走趋势优先(同账号相对值),复盘一律针对 DNA 无单篇复盘。旧 rubric 脚本与 seed 文件已删除。 +- **跨平台 style-profiler 统一规范**:每个平台专家包必须有对应 profiler tool,命令模式(`report` / `build` / `update`)、存储模式、三层产物、权重/focus、用户输入转译、统计边界统一;仅维度定义、分组命名、template 第三项起的分段结构允许平台自定义。 + +### openclaw 上游同步 v2026.7.1 → v2026.7.1-2 + +> 7.1 release branch 上的两个补丁发行版(7.1-1 / 7.1-2,均 2026-08-04 发布)。37 个 patch(002 / 007 + 35 个 browser-camoufox-pivot per-file)全部 `git apply --3way --check` 干净通过,无需重新生成。变更集中在 codex / memory-core / state-migrations / plugins / infra,与浏览器转向 patch 无重叠。**未 build**,待次日整体 apply-addon。 + +- **v2026.7.1-1**(`81ac4f3bdd`):Codex progress replies 不再中途停 turn(#106961/#108487);Memory Core 启动期修复 derived sidecar 冲突避免 Gateway fatal restart loop(#107220/#108652);WSL state permissions 容忍 EROFS(#108250/#108258);legacy migration residue 改非致命(#106101);managed plugin 更新修复 stale npm lock(#107294/#107866)。 +- **v2026.7.1-2**(`0790d9f593`):npm plugin updates 接受 singleton-array metadata(#108336),tracked 官方插件可正常安装/升级。 +- `openclaw.version` → `2026.7.1-2` / `0790d9f593ad30c940ed93b5872a8cf6d6f3cf8c`。`openclaw-weixin.version.json` 无新发行版,不变。 + +### camoufox-cli fork 跟随上游 0.7.3 + 浏览器二进制升 FF152 + +> 本轮把 wiseflow fork 的 camoufox-cli 从 `0.6.2-wiseflow.1` 升到 `0.7.3-wiseflow.1`,同步上游 0.7.x 的安装链路修复,浏览器二进制从 FF135 那一代升到 `v152.0.4-beta.28`(Firefox 152 基线,2026-07-19)。FF152 修了 "synthetic mouse input stalling under heavy load"(地址栏换 IP 死机的根因之一)、"humanized mouse trajectory dropped in FF146 migration"、juggler hang 等多个稳定性问题。 + +- **`patches/camoufox-cli/src/install.ts` 同步上游 0.7.3 三块缺失修复**: + - `stableTags()` 函数 + `assetsViaWeb()` 重构(上游 #17)——scrape path 过滤 prerelease tag,不再误拉 beta 版浏览器。 + - `browserPresent()` + install 后 `launchPath()` 兜底(上游 #17/#19)——"已安装"不只看 version.json,还要检查二进制在磁盘上;broken install 立即报错。 + - `launchPath` 加入 import,支持上面两处。 +- **`patches/camoufox-cli/package.json` 升级**: + - `version`: `0.6.2-wiseflow.1` → `0.7.3-wiseflow.1` + - `camoufox-js`: `^0.11.1` → `0.11.2`(pin 死,跟上游 0.7.3 对齐,避免意外升 0.12) + - `playwright-core`: 保持 `1.52.0`(camoufox juggler 兼容性需要) + - `wiseflowForkBaseline`: `0.6.2` → `0.7.3` +- **浏览器二进制 `v152.0.4-beta.28`(FF152 基线)**:`camoufox-js@0.11.2` 的 `CamoufoxFetcher` 指向 `daijro/camoufox` 仓库,`fetchLatest()` 跳过 prerelease,拉第一个 stable release。比 fork 之前用的 FF135 那一代新 17 个 Firefox 大版本。 +- **`scripts/update.sh` 加入 camoufox-cli fork rebuild(第 6.5 步)**: + - 老用户跑 `update.sh` 时,在 `apply-addons.sh` 之后、`pnpm build` 之前,自动 `bash patches/camoufox-cli/build.sh`(rebuild dist + npm install -g 覆盖旧版)+ `camoufox-cli install`(用新 fork 拉新浏览器二进制)。 + - 修复之前老用户升级后全局 `camoufox-cli` 还是旧版、浏览器二进制不更新的缺口。 +- **未引入多 tab 架构**:上游 0.7.3 的 `browser.ts` 引入 `TabState` + `tabs` Map + ref-counted close(多 agent 共享浏览器指纹),跟 fork 的单 page + fail-first 队列架构冲突。wiseflow 当前"一个 session 一个 agent"场景不需要多 tab,保留 fork 现状。 +- **保留的 fork 独有功能**:`shortenSession()`(支持 cron 长 session 名)、`recoverOrphanBrowser()`/`killDaemon()`/`termThenKill()`(孤儿浏览器进程树回收)、`--viewport` 参数(weibo/xianyu 二维码登录用)、fail-first 队列、`upload`/`identity` 三件套。 + +--- + # v5.6.5 (2026-08-15) ### 安装脚本与镜像运行时 bug 修复 diff --git a/README.md b/README.md index 084c16c9..3de2a7ee 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - Twitter/X、微博、知乎等平台发文 - 爆款视频追爆分析、仿写与再创作(支持抖音、B站和小红书视频链接) - 已发布作品数据监控与每日定时复盘 -- 内置小红书、抖音、twitter/x、公众号、视频号平台起号方法论 +- 内置小红书、抖音、twitter/x、公众号、视频号平台“专家包” - 信息搜集与情报:内置 Smart Search,覆盖小红书、抖音、微博、知乎、B站、Twitter、YouTube、视频号、LinkedIn、Reddit、新闻、政务、财经、学术、购物、GitHub 等 18 类信源——无需配置任何 key、纯免费 - 指定信源监控与提取 - 通过社交媒体寻找潜在客户或市场调研 @@ -32,6 +32,19 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 --- +## 🚀 **v5.7.0 更新** + +> Let's do this like an expert. + +- **专家包(Expert Pack)——像专家一样干活**:公众号、小红书、抖音、视频号能力升级为“专家包”,起号定位、对标拆解、内容生产、改稿调整、数据复盘六条工作流跑通账号运营全生命周期。你只管把任务丢过来,小贝按专业路数把活办完,不再拿一份越写越厚的通用说明书应付所有平台。 +- **内容风格 DNA——先像人写,再自我进化**:16+维细颗粒度复刻对标账号,统计学聚合做证据底座、定性判断提炼规则,最后落成内容生产 DNA template;发布数据回流后按量触发评估 -> DNA不断自我进化,迎合目标客群口味。 +- **OpenClaw 上游同步 v2026.7.1-2**。 +- **camoufox-cli 升 0.7.3 + 浏览器二进制升 FF152**:同步上游安装链路修复(不再误拉 beta 版浏览器、broken install 立即报错),FF152 解决重负载下鼠标输入卡死等稳定性顽疾。 + +详见 [CHANGELOG.md](CHANGELOG.md) + +--- + ## 🚀 **v5.6.3 更新** - **🎬 Content Producer 正式发布**:我们参考了GitHub上几乎所有热门的视频生产项目,并重点解决了纯AIGC模式容易被平台限流,以及难以融合业务素材的问题。xiaobei系统的content-producer就是你目前能够找到的最适合获客内容制作的开源免费方案! @@ -42,11 +55,8 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 > - bilibili:https://www.bilibili.com/video/BV1euMR6PEDh > - 开发与使用心得: https://mp.weixin.qq.com/s/zxvWdCMUd0XquWxujvxkSg -- **🔄 数据闭环彻底打通,为自我进化奠基**:`content-calibrator`(打分+预测)与 `published-track`(数据复盘)机制全面完善,内容产出→发布→数据回流→下一轮策略调优的闭环不再有断点,为后续小贝自主迭代选题与打法提供燃料。现在主力四平台(微信视频号、微信公众号、小红书、抖音)全系支持内容自动生产、自动发布、自动取数、自动复盘 - **安装脚本大幅优化**:对比 5.6.0,install.sh / install-atomgit.sh / install.ps1 / install-atomgit.ps1 四脚本修了若干实测踩坑——tarball 下载原子写、Windows `.env`/`daemon.env` 换行与 BOM、技能 wrapper 在 Windows 用 `.cmd` shim 替代软链、`OPENCLAW_HOME` 在 `set -u` 下报 unbound、atomgit 国内线路默认跳过 gum spinner bootstrap 避免连 GitHub 超时、Windows 软链需要开发者模式等,产品稳定性显著提高。 -- **全面测试 bug 排除**:对比5.6.0版本,在过去的三个版本里面,我们累计修复了100余个bug,xiaobei系统现在更稳定、更成熟。 - **wx-mp-hunter 更新**:原微信公众号素材接口方案因官方调整已不可用,我们更新了微信公众号获取方案 -- **底座升级到 OpenClaw v2026.7.1** 详见 [CHANGELOG.md](CHANGELOG.md) @@ -213,13 +223,6 @@ irm https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit. 小贝的背后其实是一支 AI 团队,他们有的为小贝提供运维支撑,有的扩增小贝的能力: -| Crew | 职责 | 关键技能 | -|------|------|---------| -| **小贝(main agent)** | AI 搞钱搭子,统筹全局、对接用户、内容选题与发布策略、按需招募/调度其他 crew | 多平台发布(公众号/小红书/视频号/抖音/微博/知乎/Twitter/YouTube)、`viral-chaser` 追爆、`content-calibrator` 打分、`published-track` 复盘、`smart-search` / `lead-hunting` / `intel-gathering` / `market-research` 信息搜集、`rss-reader` 信源监控、投融资与 IR 材料(`pitch-deck` / `investor-*` / `ir-record`)、`swcr-register` 软著、`xianyu-ops` 闲鱼 | -| **IT 工程师(it-engineer)** | 幕后支撑,被其他 crew spawn 协助 | 系统运维与排障、`openclaw.json` / `daemon.env` / cron 配置、`login-manager` 登录管理、平台绑定、ICP 备案、腾讯云/阿里云 CLI、GitHub/issue 追踪 | -| **创作者(content-producer)**| 专业内容制作者,承担内容生产线重活 | 视频生产(脚本→素材→TTS→渲染→合成)、网页/落地页/APP 视觉设计... | -| **销售型客服(sales-cs)** | AI 客服,可绑企业微信,客户可以直接用个人微信添加 | 售前咨询、销售推进、客户画像维护、投诉/售后分流 | - ### AI 团队的自主协作 小贝团队成员之间可以自主完成协作,而无需用户介入,这也是为什么您只需要一个微信入口就可以完整使用所有功能的原因,这意味着: diff --git a/awada/README.md b/awada/README.md index 43c5abeb..037d933c 100644 --- a/awada/README.md +++ b/awada/README.md @@ -1,7 +1,5 @@ # awada(client 侧) -> 产品拆分后本仓承担 **wiseflow-client** 角色。awada-server 已整体迁出至 relay 仓 `services/awada-server/`(决策 D4)。本仓仅保留 **awada-extension** 作为 openclaw channel,走 HTTP/WS transport 调 relay 网关(决策 D2),**不再直连 Redis**。 - ## 为什么需要 awada? 部分第三方消息服务提供商(企微 bot、个微 bot)要求固定公网 IP 接收 webhook,而 openclaw 多为本地部署。awada 在公网中转消息到本地 openclaw 实例。 @@ -12,9 +10,9 @@ 微信用户 │ (消息) ▼ -WorkTool / QiweAPI ──webhook──► awada-server(relay 仓 services/awada-server/) +provider service ──webhook──► awada-server │ - HTTP/WS 网关(OFB_KEY 鉴权) + HTTP/WS 网关(X-Awada-Key 鉴权) │ ▼ awada-extension(本地 openclaw,本仓) @@ -28,8 +26,8 @@ WorkTool / QiweAPI ──webhook──► awada-server(relay 仓 services/awad **传输契约**:见 `docs/AWADA-CLIENT-TRANSPORT.md`(唯一耦合面)。 - **inbound**(server 写 / bot 读):bot 通过 `WS /api/v1/awada/inbound?lane=` 拉取,处理完发 `{type:"ack",id}`。 -- **outbound**(bot 写 / server 读):bot 通过 `POST /api/v1/awada/outbound?lane=` 回执,`meta` 必填 `platform`/`channel_id`/`user_id_external`(从 inbound `event.meta` 原样回传)。 -- **Redis** → relay 内部,**不对客户端暴露**(D2)。客户端只见 `relayBaseUrl` + `ofbKey` + `lane`。 +- **outbound**(bot 写 / server 读):bot 通过 `POST /api/v1/awada/outbound?lane=` 回执,`meta` 必填 `channel_id`/`user_id_external`(从 inbound `event.meta` 原样回传);`platform` 回复时回传、主动外呼省略。 +- **Redis** → relay 内部,**不对客户端暴露**(D2)。客户端最少只需配 `awadaKey`。 **核心组件(拆分后归属):** - **awada-server** → relay 仓 `services/awada-server/`。客户端不部署、不持凭据。 @@ -54,27 +52,24 @@ openclaw 配置文件中添加 `channels.awada` 节点: { "channels": { "awada": { - "enabled": true, - "relayBaseUrl": "https://relay.wiseflow.example.com", - "ofbKey": "", - "lane": "user", - "platform": "worktool:mybot" + "awadaKey": "" } } } ``` -> **传输改造点**:原 `redisUrl` 字段已作废,替换为 `relayBaseUrl` + `ofbKey`。extension 走 `WS /api/v1/awada/inbound?lane=`(读 inbound + ack)+ `POST /api/v1/awada/outbound?lane=`(写回执),带 `X-OFB-Key` header。Redis 直连模式不再支持。 +> **传输改造点**:原 `redisUrl` 字段已作废,替换为 `relayBaseUrl` + `awadaKey`。extension 走 `WS /api/v1/awada/inbound?lane=`(读 inbound + ack)+ `POST /api/v1/awada/outbound?lane=`(写回执),带 `X-Awada-Key` header。Redis 直连模式不再支持。 +> +> `awadaKey` 与签名服务用的 `OFB_KEY` 是两份独立凭证,relay admin 分别签发。`lane` 在 relay 侧 provision 时已绑死 platform,客户端不发 `platform`。 -**awada-extension 配置项(拆分后):** +**awada-extension 配置项:** | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `enabled` | boolean | `true` | 是否启用 | -| `relayBaseUrl` | string | — | relay 网关端点,**必填**(http/https) | -| `ofbKey` | string | — | OFB_KEY,**必填**,由 relay admin 签发(含 `awada:lane:` scope) | -| `lane` | string | `"user"` | 订阅的 lane | -| `platform` | string | — | 平台标识,主动发消息时必填 | +| `awadaKey` | string | — | awada key,**必填**,由 relay admin 签发(含 `awada:lane:` scope),作为 `X-Awada-Key` header 发出 | +| `relayBaseUrl` | string | `https://relay.openclaw-for-business.com` | relay 网关端点(http/https);官方域名可不配 | +| `lane` | string | `""`(server 默认 `User`) | 订阅的 lane,可选;不传时服务器默认使用 `User` lane | | `dmPolicy` | string | `"open"` | `open`/`pairing`/`allowlist` | | `allowFrom` | string[] | `[]` | `allowlist` 模式下允许的用户 ID | | `perMsgMaxLen` | number | — | 单条消息最大字符数,超长自动拆分 | @@ -85,12 +80,7 @@ openclaw 配置文件中添加 `channels.awada` 节点: { "channels": { "awada": { - "enabled": true, - "relayBaseUrl": "https://relay.wiseflow.example.com", - "ofbKey": "", - "lane": "user", - "platform": "worktool:mybot", - "dmPolicy": "open", + "awadaKey": "", "perMsgMaxLen": 500 } }, @@ -110,10 +100,6 @@ openclaw 配置文件中添加 `channels.awada` 节点: ## 多 Bot / 多实例 -- **多 bot**:在 relay 侧 awada-server 配置多个 bot,每个 bot 绑定一个 lane(1:1)。客户端用不同 `ofbKey`/`lane` 订阅。 +- **多 bot**:在 relay 侧 awada-server 配置多个 bot,每个 bot 绑定一个 lane(1:1)。客户端用不同 `awadaKey`/`lane` 订阅(`lane` 可省略,server 默认 `User`)。 - **多 openclaw 实例**:不同实例订阅不同 lane。 - 客户端无需感知 Redis db 隔离(relay 内部处理)。 - -## awada-server 在哪? - -`services/awada-server/` 已迁至 relay 仓(`git-server:repos/wiseflow-relay.git`),交接文档见该仓 `docs/HANDOVER.md`。本仓不再包含 server 代码。启用 sales-cs(D10)由 IT engineer 操作改 `enabled: true` + 软链 business_knowledge。 diff --git a/awada/openclaw.plugin.json b/awada/openclaw.plugin.json index 67df9cd0..6e84b2e1 100644 --- a/awada/openclaw.plugin.json +++ b/awada/openclaw.plugin.json @@ -11,9 +11,8 @@ "properties": { "enabled": { "type": "boolean" }, "relayBaseUrl": { "type": "string" }, - "ofbKey": { "type": "string" }, + "awadaKey": { "type": "string" }, "lane": { "type": "string" }, - "platform": { "type": "string" }, "dmPolicy": { "type": "string", "enum": ["open", "pairing", "allowlist"] }, "allowFrom": { "type": "array", "items": { "type": "string" } }, "perMsgMaxLen": { "type": "integer", "minimum": 1 }, @@ -37,9 +36,8 @@ "properties": { "enabled": { "type": "boolean" }, "relayBaseUrl": { "type": "string" }, - "ofbKey": { "type": "string" }, + "awadaKey": { "type": "string" }, "lane": { "type": "string" }, - "platform": { "type": "string" }, "dmPolicy": { "type": "string", "enum": ["open", "pairing", "allowlist"] }, "allowFrom": { "type": "array", "items": { "type": "string" } }, "perMsgMaxLen": { "type": "integer", "minimum": 1 }, diff --git a/awada/src/accounts.test.ts b/awada/src/accounts.test.ts index 0972136e..a717818a 100644 --- a/awada/src/accounts.test.ts +++ b/awada/src/accounts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_RELAY_BASE_URL, listAwadaAccountIds, resolveAwadaAccount, resolveDefaultAwadaAccountId, @@ -10,64 +11,81 @@ function makeConfig(awada?: Record): ClawdbotConfig { return { channels: awada !== undefined ? { awada } : undefined } as ClawdbotConfig; } +const FULL = { awadaKey: "awada_123", lane: "user" }; + describe("resolveAwadaAccount", () => { it("returns default values when no awada config is present", () => { const account = resolveAwadaAccount({ cfg: makeConfig() }); expect(account.accountId).toBe("default"); expect(account.enabled).toBe(true); expect(account.configured).toBe(false); - expect(account.relayBaseUrl).toBeUndefined(); - expect(account.ofbKey).toBeUndefined(); - expect(account.lane).toBe("user"); + expect(account.relayBaseUrl).toBe(DEFAULT_RELAY_BASE_URL); + expect(account.awadaKey).toBeUndefined(); + expect(account.lane).toBe(""); }); - it("resolves relayBaseUrl+ofbKey and marks configured=true", () => { - const account = resolveAwadaAccount({ - cfg: makeConfig({ relayBaseUrl: "https://relay.example.com", ofbKey: "ofb_123" }), - }); + it("resolves awadaKey and marks configured=true with only awadaKey", () => { + const account = resolveAwadaAccount({ cfg: makeConfig({ awadaKey: "awada_123" }) }); expect(account.configured).toBe(true); - expect(account.relayBaseUrl).toBe("https://relay.example.com"); - expect(account.ofbKey).toBe("ofb_123"); + expect(account.awadaKey).toBe("awada_123"); + expect(account.relayBaseUrl).toBe(DEFAULT_RELAY_BASE_URL); + expect(account.lane).toBe(""); }); - it("trims whitespace from relayBaseUrl/ofbKey", () => { + it("resolves relayBaseUrl+awadaKey+lane and marks configured=true", () => { + const account = resolveAwadaAccount({ cfg: makeConfig(FULL) }); + expect(account.configured).toBe(true); + expect(account.relayBaseUrl).toBe(DEFAULT_RELAY_BASE_URL); + expect(account.awadaKey).toBe("awada_123"); + expect(account.lane).toBe("user"); + }); + + it("trims whitespace from relayBaseUrl/awadaKey/lane", () => { const account = resolveAwadaAccount({ - cfg: makeConfig({ relayBaseUrl: " https://relay.example.com ", ofbKey: " ofb_123 " }), + cfg: makeConfig({ + relayBaseUrl: " https://relay.example.com ", + awadaKey: " awada_123 ", + lane: " user ", + }), }); expect(account.relayBaseUrl).toBe("https://relay.example.com"); - expect(account.ofbKey).toBe("ofb_123"); + expect(account.awadaKey).toBe("awada_123"); + expect(account.lane).toBe("user"); }); - it("marks configured=false when ofbKey missing", () => { + it("marks configured=false when awadaKey missing", () => { const account = resolveAwadaAccount({ - cfg: makeConfig({ relayBaseUrl: "https://relay.example.com" }), + cfg: makeConfig({ relayBaseUrl: "https://relay.example.com", lane: "user" }), }); expect(account.configured).toBe(false); }); - it("marks configured=false when relayBaseUrl missing", () => { - const account = resolveAwadaAccount({ cfg: makeConfig({ ofbKey: "ofb_123" }) }); - expect(account.configured).toBe(false); + it("defaults relayBaseUrl to the official relay domain when unset", () => { + const account = resolveAwadaAccount({ cfg: makeConfig({ awadaKey: "awada_123" }) }); + expect(account.relayBaseUrl).toBe(DEFAULT_RELAY_BASE_URL); + expect(account.configured).toBe(true); }); - it("respects enabled=false", () => { + it("marks configured=true when lane missing (server defaults to User)", () => { const account = resolveAwadaAccount({ - cfg: makeConfig({ enabled: false, relayBaseUrl: "https://relay.example.com", ofbKey: "k" }), + cfg: makeConfig({ awadaKey: "awada_123" }), }); + expect(account.configured).toBe(true); + expect(account.lane).toBe(""); + }); + + it("respects enabled=false", () => { + const account = resolveAwadaAccount({ cfg: makeConfig({ ...FULL, enabled: false }) }); expect(account.enabled).toBe(false); }); it("defaults enabled to true when not set", () => { - const account = resolveAwadaAccount({ - cfg: makeConfig({ relayBaseUrl: "https://relay.example.com", ofbKey: "k" }), - }); + const account = resolveAwadaAccount({ cfg: makeConfig(FULL) }); expect(account.enabled).toBe(true); }); it("uses custom lane when provided", () => { - const account = resolveAwadaAccount({ - cfg: makeConfig({ lane: "cs" }), - }); + const account = resolveAwadaAccount({ cfg: makeConfig({ ...FULL, lane: "cs" }) }); expect(account.lane).toBe("cs"); }); diff --git a/awada/src/accounts.ts b/awada/src/accounts.ts index 77b34df9..3cebae96 100644 --- a/awada/src/accounts.ts +++ b/awada/src/accounts.ts @@ -2,7 +2,8 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/channel-plugin-common"; import type { ClawdbotConfig } from "openclaw/plugin-sdk"; import type { AwadaConfig, ResolvedAwadaAccount } from "./types.js"; -const DEFAULT_LANE = "user"; +/** Official relay gateway endpoint — used when channels.awada.relayBaseUrl is not set. */ +export const DEFAULT_RELAY_BASE_URL = "https://relay.openclaw-for-business.com"; function getAwadaCfg(cfg: ClawdbotConfig): AwadaConfig | undefined { return cfg.channels?.awada as AwadaConfig | undefined; @@ -15,19 +16,20 @@ export function resolveAwadaAccount(params: { const awadaCfg = getAwadaCfg(params.cfg); const accountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID; const enabled = awadaCfg?.enabled !== false; - const relayBaseUrl = awadaCfg?.relayBaseUrl?.trim() || undefined; - const ofbKey = awadaCfg?.ofbKey?.trim() || undefined; - // Configured only when both relay endpoint and key are present. - const configured = Boolean(relayBaseUrl && ofbKey); + // relayBaseUrl defaults to the official relay domain; only awadaKey is truly required. + // lane is optional — when omitted, the server defaults to the "User" lane. + const relayBaseUrl = awadaCfg?.relayBaseUrl?.trim() || DEFAULT_RELAY_BASE_URL; + const awadaKey = awadaCfg?.awadaKey?.trim() || undefined; + const lane = awadaCfg?.lane?.trim() || ""; + const configured = Boolean(awadaKey); return { accountId, enabled, configured, relayBaseUrl, - ofbKey, - lane: awadaCfg?.lane?.trim() || DEFAULT_LANE, - platform: awadaCfg?.platform?.trim() || undefined, + awadaKey, + lane, config: awadaCfg ?? {}, }; } diff --git a/awada/src/channel.ts b/awada/src/channel.ts index 9f1d8390..80e14798 100644 --- a/awada/src/channel.ts +++ b/awada/src/channel.ts @@ -56,9 +56,8 @@ export const awadaPlugin: ChannelPlugin = { properties: { enabled: { type: "boolean" }, relayBaseUrl: { type: "string" }, - ofbKey: { type: "string" }, + awadaKey: { type: "string" }, lane: { type: "string" }, - platform: { type: "string" }, dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] }, allowFrom: { type: "array", items: { type: "string" } }, perMsgMaxLen: { type: "integer", minimum: 1 }, diff --git a/awada/src/config-schema.ts b/awada/src/config-schema.ts index c9c14963..cdfe8c08 100644 --- a/awada/src/config-schema.ts +++ b/awada/src/config-schema.ts @@ -4,14 +4,15 @@ export { z }; export const AwadaConfigSchema = z .object({ enabled: z.boolean().optional(), - /** Relay gateway base URL, e.g. "https://relay.example.com". Bot talks HTTP/WS to relay, never Redis directly. */ + /** Relay gateway base URL, e.g. "https://relay.example.com". Bot talks HTTP/WS to relay, never Redis directly. + * Defaults to the official relay domain (https://relay.openclaw-for-business.com) when unset. */ relayBaseUrl: z.string().optional(), - /** OFB_KEY issued by relay admin; carries awada:lane: scopes. Sent as X-OFB-Key header. */ - ofbKey: z.string().optional(), - /** Lane to subscribe to. Maps to awada:events:inbound:. Default: "user" */ + /** Awada key issued by relay admin; carries awada:lane: scopes. Sent as X-Awada-Key header. + * Distinct from the sign-service OFB_KEY — relay admin issues the two separately. */ + awadaKey: z.string().optional(), + /** Lane to subscribe to. Maps to awada:events:inbound:. Optional — when omitted, the server + * defaults to the "User" lane. */ lane: z.string().optional(), - /** Platform identifier used when publishing proactive messages (e.g. "worktool:mybot"). */ - platform: z.string().optional(), /** DM policy: open (anyone), pairing (requires approval), or allowlist */ dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional(), /** Allowed user_id_external values for allowlist/pairing */ diff --git a/awada/src/message-actions.ts b/awada/src/message-actions.ts index 34a14f94..4b1a5b4d 100644 --- a/awada/src/message-actions.ts +++ b/awada/src/message-actions.ts @@ -24,8 +24,8 @@ export const awadaMessageActions: ChannelMessageActionAdapter = { }); const account = resolveAwadaAccount({ cfg: ctx.cfg, accountId: ctx.accountId }); - if (!account.relayBaseUrl || !account.ofbKey) { - throw new Error("[awada] relayBaseUrl/ofbKey not configured"); + if (!account.relayBaseUrl || !account.awadaKey) { + throw new Error("[awada] not configured (need awadaKey)"); } // Prefer the resolved target from params.to (set by core's target resolver), @@ -42,7 +42,7 @@ export const awadaMessageActions: ChannelMessageActionAdapter = { const media = buildMediaContentFromName({ file_name: fileName }); const streamId = await sendMediaToAwada({ relayBaseUrl: account.relayBaseUrl, - ofbKey: account.ofbKey, + awadaKey: account.awadaKey, lane: account.lane, target, media, diff --git a/awada/src/message-handler.ts b/awada/src/message-handler.ts index f8721b6d..0e429001 100644 --- a/awada/src/message-handler.ts +++ b/awada/src/message-handler.ts @@ -226,8 +226,8 @@ async function _dispatchAwadaEvent(entry: AwadaDebounceEntry): Promise { const error = runtime?.error ?? console.error; const account = resolveAwadaAccount({ cfg, accountId }); - if (!account.relayBaseUrl || !account.ofbKey) { - error(`awada[${accountId}]: relayBaseUrl/ofbKey not configured, skipping event ${event.event_id}`); + if (!account.relayBaseUrl || !account.awadaKey) { + error(`awada[${accountId}]: not configured (need awadaKey), skipping event ${event.event_id}`); return; } const { meta, payload, event_id, correlation_id, trace_id } = event; @@ -262,7 +262,7 @@ async function _dispatchAwadaEvent(entry: AwadaDebounceEntry): Promise { error(`awada[${accountId}]: audio transcription failed: ${result.error}`); await sendTextToAwada({ relayBaseUrl: account.relayBaseUrl!, - ofbKey: account.ofbKey!, + awadaKey: account.awadaKey!, lane: account.lane, target, text: AUDIO_FAIL_MESSAGE, @@ -274,7 +274,7 @@ async function _dispatchAwadaEvent(entry: AwadaDebounceEntry): Promise { error(`awada[${accountId}]: audio fetch/transcribe error: ${String(err)}`); await sendTextToAwada({ relayBaseUrl: account.relayBaseUrl!, - ofbKey: account.ofbKey!, + awadaKey: account.awadaKey!, lane: account.lane, target, text: AUDIO_FAIL_MESSAGE, @@ -366,7 +366,7 @@ async function _dispatchAwadaEvent(entry: AwadaDebounceEntry): Promise { agentId: route.agentId, runtime: runtime as RuntimeEnv, relayBaseUrl: account.relayBaseUrl!, - ofbKey: account.ofbKey!, + awadaKey: account.awadaKey!, lane: account.lane, target, inboundEventId: event_id, diff --git a/awada/src/monitor.ts b/awada/src/monitor.ts index 22293ff8..898e8ad7 100644 --- a/awada/src/monitor.ts +++ b/awada/src/monitor.ts @@ -20,8 +20,8 @@ export async function monitorAwadaProvider(opts: MonitorAwadaOpts = {}): Promise if (!cfg) throw new Error("Config is required for awada monitor"); const account = resolveAwadaAccount({ cfg, accountId }); - if (!account.enabled || !account.configured || !account.relayBaseUrl || !account.ofbKey) { - throw new Error("Awada channel not enabled or configured (missing relayBaseUrl/ofbKey)"); + if (!account.enabled || !account.configured || !account.relayBaseUrl || !account.awadaKey) { + throw new Error("Awada channel not enabled or configured (need awadaKey)"); } const log = runtime?.log ?? console.log; @@ -30,7 +30,7 @@ export async function monitorAwadaProvider(opts: MonitorAwadaOpts = {}): Promise await runGatewayClient({ relayBaseUrl: account.relayBaseUrl, - ofbKey: account.ofbKey, + awadaKey: account.awadaKey, lane: account.lane, abortSignal, log, diff --git a/awada/src/onboarding.ts b/awada/src/onboarding.ts index 177ea267..5ddde2c9 100644 --- a/awada/src/onboarding.ts +++ b/awada/src/onboarding.ts @@ -1,5 +1,6 @@ import type { ChannelSetupWizard, DmPolicy, OpenClawConfig } from "openclaw/plugin-sdk/setup"; import { createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { DEFAULT_RELAY_BASE_URL } from "./accounts.js"; import { probeAwada } from "./probe.js"; import type { AwadaConfig } from "./types.js"; @@ -11,7 +12,7 @@ function getAwadaCfg(cfg: OpenClawConfig): AwadaConfig | undefined { function isAwadaConfigured(cfg: OpenClawConfig): boolean { const c = getAwadaCfg(cfg); - return Boolean(c?.relayBaseUrl?.trim() && c?.ofbKey?.trim()); + return Boolean(c?.awadaKey?.trim()); } function setAwadaAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { @@ -57,15 +58,15 @@ export const awadaSetupWizard: ChannelSetupWizard = { resolveShouldPromptAccountIds: () => false, status: { configuredLabel: "configured", - unconfiguredLabel: "needs relay endpoint + OFB_KEY", + unconfiguredLabel: "needs awadaKey", configuredHint: "configured", - unconfiguredHint: "needs relay endpoint + OFB_KEY", + unconfiguredHint: "needs awadaKey", configuredScore: 2, unconfiguredScore: 0, resolveConfigured: ({ cfg }) => isAwadaConfigured(cfg), resolveStatusLines: async ({ cfg, configured }) => { const awadaCfg = getAwadaCfg(cfg); - const relayBaseUrl = awadaCfg?.relayBaseUrl?.trim(); + const relayBaseUrl = awadaCfg?.relayBaseUrl?.trim() || DEFAULT_RELAY_BASE_URL; let probeResult = null; if (configured && relayBaseUrl) { try { @@ -75,7 +76,7 @@ export const awadaSetupWizard: ChannelSetupWizard = { } } if (!configured) { - return ["Awada: needs relayBaseUrl + ofbKey"]; + return ["Awada: needs awadaKey"]; } if (probeResult?.ok) { return ["Awada: relay reachable"]; @@ -83,62 +84,68 @@ export const awadaSetupWizard: ChannelSetupWizard = { return ["Awada: configured (relay not verified)"]; }, resolveSelectionHint: ({ cfg }) => - isAwadaConfigured(cfg) ? "configured" : "needs relay endpoint + OFB_KEY", + isAwadaConfigured(cfg) ? "configured" : "needs awadaKey", resolveQuickstartScore: ({ cfg }) => (isAwadaConfigured(cfg) ? 2 : 0), }, credentials: [], finalize: async ({ cfg, prompter }) => { const awadaCfg = getAwadaCfg(cfg); const currentUrl = awadaCfg?.relayBaseUrl?.trim() ?? ""; - const currentKey = awadaCfg?.ofbKey?.trim() ?? ""; + const currentKey = awadaCfg?.awadaKey?.trim() ?? ""; + const currentLane = awadaCfg?.lane?.trim() ?? ""; await prompter.note( [ "Configure awada channel to receive WeChat messages via the relay gateway.", "You need:", " 1. A running relay with awada-server gateway (exposes /api/v1/awada)", - " 2. relayBaseUrl (e.g. https://relay.example.com)", - " 3. OFB_KEY issued by relay admin (carries awada:lane: scope)", - " 4. Lane to subscribe to (default: user)", - " 5. Platform identifier for proactive sends (e.g. worktool:mybot)", + " 2. awadaKey issued by relay admin (carries awada:lane: scope) — required", + " 3. relayBaseUrl (optional, defaults to the official relay domain)", + " 4. Lane (optional, server defaults to \"User\" when omitted)", ].join("\n"), "Awada setup", ); + const awadaKey = String( + await prompter.text({ + message: "awadaKey", + placeholder: "awada_...", + initialValue: currentKey, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + const relayBaseUrl = String( await prompter.text({ - message: "Relay base URL", - placeholder: "https://relay.example.com", + message: `Relay base URL (blank = default ${DEFAULT_RELAY_BASE_URL})`, + placeholder: DEFAULT_RELAY_BASE_URL, initialValue: currentUrl, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }), ).trim(); - const ofbKey = String( + const laneInput = String( await prompter.text({ - message: "OFB_KEY", - placeholder: "ofb_...", - initialValue: currentKey, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + message: 'Lane (blank = server default "User")', + placeholder: "lane id from relay admin", + initialValue: currentLane, }), ).trim(); - let next: OpenClawConfig = { + const awadaChannel: AwadaConfig = { ...awadaCfg, enabled: true, awadaKey }; + if (relayBaseUrl) awadaChannel.relayBaseUrl = relayBaseUrl; + if (laneInput) awadaChannel.lane = laneInput; + const next: OpenClawConfig = { ...cfg, channels: { ...cfg.channels, - awada: { - ...awadaCfg, - enabled: true, - relayBaseUrl, - ofbKey, - }, + awada: awadaChannel, }, }; // Test connection + const probeUrl = relayBaseUrl || DEFAULT_RELAY_BASE_URL; try { - const probe = await probeAwada({ relayBaseUrl }); + const probe = await probeAwada({ relayBaseUrl: probeUrl }); if (probe.ok) { await prompter.note("Relay reachable!", "Awada connection test"); } else { @@ -151,49 +158,6 @@ export const awadaSetupWizard: ChannelSetupWizard = { await prompter.note(`Connection test failed: ${String(err)}`, "Awada connection test"); } - // Lane configuration - const currentLane = awadaCfg?.lane?.trim() ?? "user"; - const laneInput = String( - await prompter.text({ - message: "Lane to subscribe to", - placeholder: "user", - initialValue: currentLane, - }), - ).trim(); - const resolvedLane = laneInput || "user"; - next = { - ...next, - channels: { - ...next.channels, - awada: { - ...(next.channels?.awada as AwadaConfig), - lane: resolvedLane, - }, - }, - }; - - // Platform configuration (used for proactive sends) - const currentPlatform = awadaCfg?.platform?.trim() ?? ""; - const platformInput = String( - await prompter.text({ - message: "Platform identifier for proactive sends (e.g. worktool:mybot)", - placeholder: "worktool:mybot", - initialValue: currentPlatform, - }), - ).trim(); - if (platformInput) { - next = { - ...next, - channels: { - ...next.channels, - awada: { - ...(next.channels?.awada as AwadaConfig), - platform: platformInput, - }, - }, - }; - } - return { cfg: next }; }, dmPolicy: awadaDmPolicy, diff --git a/awada/src/outbound.ts b/awada/src/outbound.ts index 1955b554..52db6939 100644 --- a/awada/src/outbound.ts +++ b/awada/src/outbound.ts @@ -14,7 +14,7 @@ import type { AwadaConfig } from "./types.js"; import { isNoReplyText } from "./silent-reply.js"; /** - * Resolve the gateway send params (relayBaseUrl/ofbKey/lane) for an account. + * Resolve the gateway send params (relayBaseUrl/awadaKey/lane) for an account. * Throws if the account isn't configured for gateway transport. */ function resolveGatewaySend(params: { @@ -22,12 +22,12 @@ function resolveGatewaySend(params: { accountId?: string | null; }) { const account = resolveAwadaAccount({ cfg: params.cfg, accountId: params.accountId ?? undefined }); - if (!account.relayBaseUrl || !account.ofbKey) { - throw new Error("[awada] relayBaseUrl/ofbKey not configured"); + if (!account.relayBaseUrl || !account.awadaKey) { + throw new Error("[awada] not configured (need awadaKey)"); } return { relayBaseUrl: account.relayBaseUrl, - ofbKey: account.ofbKey, + awadaKey: account.awadaKey, lane: account.lane, account, }; @@ -40,13 +40,13 @@ function resolveGatewaySend(params: { async function sendChunked(params: { cfg: ClawdbotConfig; relayBaseUrl: string; - ofbKey: string; + awadaKey: string; lane: string; target: ReturnType; text: string; sourceEventId?: string; }): Promise { - const { cfg, relayBaseUrl, ofbKey, lane, target, sourceEventId } = params; + const { cfg, relayBaseUrl, awadaKey, lane, target, sourceEventId } = params; const awadaCfg = cfg.channels?.awada as AwadaConfig | undefined; const perMsgMaxLen = awadaCfg?.perMsgMaxLen; const chunks = @@ -58,7 +58,7 @@ async function sendChunked(params: { for (const chunk of chunks) { lastId = await sendTextToAwada({ relayBaseUrl, - ofbKey, + awadaKey, lane, target: target!, text: chunk, @@ -85,7 +85,7 @@ export const awadaOutbound: ChannelOutboundAdapter = { const streamId = await sendChunked({ cfg, relayBaseUrl: gw.relayBaseUrl, - ofbKey: gw.ofbKey, + awadaKey: gw.awadaKey, lane: gw.lane, target, text: text ?? "", @@ -109,7 +109,7 @@ export const awadaOutbound: ChannelOutboundAdapter = { const media = buildMediaContentFromUrl(url); const streamId = await sendMediaToAwada({ relayBaseUrl: gw.relayBaseUrl, - ofbKey: gw.ofbKey, + awadaKey: gw.awadaKey, lane: gw.lane, target, media, @@ -120,7 +120,7 @@ export const awadaOutbound: ChannelOutboundAdapter = { const media = buildMediaContentFromName({ file_name: url }); const streamId = await sendMediaToAwada({ relayBaseUrl: gw.relayBaseUrl, - ofbKey: gw.ofbKey, + awadaKey: gw.awadaKey, lane: gw.lane, target, media, @@ -135,7 +135,7 @@ export const awadaOutbound: ChannelOutboundAdapter = { const streamId = await sendChunked({ cfg, relayBaseUrl: gw.relayBaseUrl, - ofbKey: gw.ofbKey, + awadaKey: gw.awadaKey, lane: gw.lane, target, text: body, diff --git a/awada/src/probe.ts b/awada/src/probe.ts index e8f7f3bf..ffa4dfad 100644 --- a/awada/src/probe.ts +++ b/awada/src/probe.ts @@ -25,7 +25,7 @@ export function validateAwadaRelayBaseUrl(relayBaseUrl: string): string | null { /** * Probe relay gateway connectivity for an awada account. * Hits GET /api/v1/awada/health (no auth). Returns ok=true if the gateway is reachable - * and its Redis is up. Auth (OFB_KEY + lane scope) is verified on the first real call. + * and its Redis is up. Auth (awadaKey via X-Awada-Key header + lane scope) is verified on the first real call. */ export async function probeAwada(params: { relayBaseUrl?: string; diff --git a/awada/src/publisher.ts b/awada/src/publisher.ts index 580df9e6..503829ce 100644 --- a/awada/src/publisher.ts +++ b/awada/src/publisher.ts @@ -23,15 +23,12 @@ export async function publishTextToAwada(params: { const { cfg, accountId, userId, channelId, tenantId = "", text } = params; const account = resolveAwadaAccount({ cfg, accountId }); - if (!account.relayBaseUrl || !account.ofbKey) { - throw new Error("[awada] relayBaseUrl/ofbKey not configured"); - } - if (!account.platform) { - throw new Error("[awada] platform not configured — required for proactive sends"); + if (!account.relayBaseUrl || !account.awadaKey) { + throw new Error("[awada] not configured (need awadaKey)"); } + // platform is omitted — the relay derives it from the lane binding (provisioned server-side). const target = buildOutboundTarget({ - platform: account.platform, lane: account.lane, user_id_external: userId, channel_id: channelId, @@ -40,7 +37,7 @@ export async function publishTextToAwada(params: { const result = await postOutbound({ relayBaseUrl: account.relayBaseUrl, - ofbKey: account.ofbKey, + awadaKey: account.awadaKey, lane: account.lane, target, payload: [{ type: "text", text }], diff --git a/awada/src/redis-types.ts b/awada/src/redis-types.ts index 1b96c4b0..a360f3f6 100644 --- a/awada/src/redis-types.ts +++ b/awada/src/redis-types.ts @@ -61,7 +61,9 @@ export interface InboundEvent { } export interface OutboundTarget { - platform: string; + /** Platform routing key. Present on replies (from inbound meta); omitted on proactive sends + * — the relay derives it from the lane binding. */ + platform?: string; tenant_id: string; lane: string; user_id_external: string; @@ -85,13 +87,12 @@ export interface OutboundEvent { /** * Meta sent on POST /outbound (and WS reply frames) — see docs/AWADA-CLIENT-TRANSPORT.md §3. - * `platform` / `channel_id` / `user_id_external` are REQUIRED: relay routes the reply back to - * the platform solely from these fields (it does NOT reverse-lookup the inbound by source_event_id). - * The simplest correct construction is to passthrough the inbound `event.meta` and override - * `source_event_id` with the inbound `event_id`. + * On replies, `platform` / `channel_id` / `user_id_external` are all present (passthrough from + * inbound `event.meta`). On proactive sends, `platform` is omitted — the relay derives it from + * the lane binding. `channel_id` / `user_id_external` are always required. */ export interface OutboundMeta { - platform: string; + platform?: string; channel_id: string; user_id_external: string; tenant_id?: string; diff --git a/awada/src/reply-dispatcher.ts b/awada/src/reply-dispatcher.ts index 0f993401..e00bfc65 100644 --- a/awada/src/reply-dispatcher.ts +++ b/awada/src/reply-dispatcher.ts @@ -17,7 +17,7 @@ export type CreateAwadaReplyDispatcherParams = { agentId: string; runtime: RuntimeEnv; relayBaseUrl: string; - ofbKey: string; + awadaKey: string; lane: string; target: OutboundTarget; inboundEventId: string; @@ -43,7 +43,7 @@ export function createAwadaReplyDispatcher(params: CreateAwadaReplyDispatcherPar cfg, runtime, relayBaseUrl, - ofbKey, + awadaKey, lane, target, inboundEventId, @@ -79,7 +79,7 @@ export function createAwadaReplyDispatcher(params: CreateAwadaReplyDispatcherPar for (const chunk of chunks) { const p = sendTextToAwada({ relayBaseUrl, - ofbKey, + awadaKey, lane, target, text: chunk, @@ -101,7 +101,7 @@ export function createAwadaReplyDispatcher(params: CreateAwadaReplyDispatcherPar const media = buildMediaContentFromUrl(url); const p = sendMediaToAwada({ relayBaseUrl, - ofbKey, + awadaKey, lane, target, media, @@ -122,7 +122,7 @@ export function createAwadaReplyDispatcher(params: CreateAwadaReplyDispatcherPar const media: FileObject = { type: "file", file_id: fileId, file_name: fileName }; const p = sendMediaToAwada({ relayBaseUrl, - ofbKey, + awadaKey, lane, target, media, diff --git a/awada/src/send.ts b/awada/src/send.ts index 72a1c182..d3bbb7a3 100644 --- a/awada/src/send.ts +++ b/awada/src/send.ts @@ -17,7 +17,7 @@ import type { */ export type GatewaySendParams = { relayBaseUrl: string; - ofbKey: string; + awadaKey: string; lane: string; target: OutboundTarget; payload: ContentObject[]; @@ -43,16 +43,16 @@ export function buildOutboundTarget(meta: { tenant_id: string; channel_id: string; user_id_external: string; - platform: string; + platform?: string; conversation_id?: string; }): OutboundTarget { const target: OutboundTarget = { - platform: meta.platform, tenant_id: meta.tenant_id, lane: meta.lane, user_id_external: meta.user_id_external, channel_id: meta.channel_id, }; + if (meta.platform) target.platform = meta.platform; if (meta.conversation_id) { target.conversation_id = meta.conversation_id; } @@ -62,10 +62,10 @@ export function buildOutboundTarget(meta: { /** Build the OutboundMeta required by POST /outbound from a target + source event id. */ export function buildOutboundMeta(target: OutboundTarget, sourceEventId?: string): OutboundMeta { const meta: OutboundMeta = { - platform: target.platform, channel_id: target.channel_id, user_id_external: target.user_id_external, }; + if (target.platform) meta.platform = target.platform; if (target.tenant_id) meta.tenant_id = target.tenant_id; if (target.conversation_id) meta.session_id = target.conversation_id; if (sourceEventId) meta.source_event_id = sourceEventId; @@ -78,12 +78,12 @@ function outboundUrl(relayBaseUrl: string, lane: string): string { } export async function postOutbound(params: GatewaySendParams): Promise<{ streamId: string; eventId: string }> { - const { relayBaseUrl, ofbKey, lane, target, payload, sourceEventId } = params; + const { relayBaseUrl, awadaKey, lane, target, payload, sourceEventId } = params; const meta = buildOutboundMeta(target, sourceEventId); const res = await fetch(outboundUrl(relayBaseUrl, lane), { method: "POST", headers: { - "X-OFB-Key": ofbKey, + "X-Awada-Key": awadaKey, "Content-Type": "application/json", }, body: JSON.stringify({ payload, meta }), @@ -111,16 +111,16 @@ export async function postOutbound(params: GatewaySendParams): Promise<{ streamI export async function sendTextToAwada(params: { relayBaseUrl: string; - ofbKey: string; + awadaKey: string; lane: string; target: OutboundTarget; text: string; sourceEventId?: string; }): Promise { - const { relayBaseUrl, ofbKey, lane, target, text, sourceEventId } = params; + const { relayBaseUrl, awadaKey, lane, target, text, sourceEventId } = params; const result = await postOutbound({ relayBaseUrl, - ofbKey, + awadaKey, lane, target, payload: [{ type: "text", text }], @@ -134,16 +134,16 @@ export async function sendTextToAwada(params: { */ export async function sendMediaToAwada(params: { relayBaseUrl: string; - ofbKey: string; + awadaKey: string; lane: string; target: OutboundTarget; media: ContentObject; sourceEventId?: string; }): Promise { - const { relayBaseUrl, ofbKey, lane, target, media, sourceEventId } = params; + const { relayBaseUrl, awadaKey, lane, target, media, sourceEventId } = params; const result = await postOutbound({ relayBaseUrl, - ofbKey, + awadaKey, lane, target, payload: [media], diff --git a/awada/src/transport.ts b/awada/src/transport.ts index 8e5c973e..3814814e 100644 --- a/awada/src/transport.ts +++ b/awada/src/transport.ts @@ -2,7 +2,7 @@ * Gateway client — WS inbound channel + ack. * * Per docs/AWADA-CLIENT-TRANSPORT.md §4: bot opens WS /api/v1/awada/inbound?lane= - * with X-OFB-Key header. Relay pushes `{id, event}` frames. Bot processes the event then + * with X-Awada-Key header. Relay pushes `{id, event}` frames. Bot processes the event then * sends `{type:"ack",id}`. Unacked events stay in the PEL and are reclaimed by the gateway * (XAUTOCLAIM, min-idle 65s) after reconnect — at-least-once semantics. * @@ -25,7 +25,7 @@ export function buildInboundWsUrl(relayBaseUrl: string, lane: string): string { export type GatewayClientOpts = { relayBaseUrl: string; - ofbKey: string; + awadaKey: string; lane: string; /** Bot processing for one inbound event. Resolves when the bot is done (reply already POSTed). */ onEvent: (id: string, event: InboundEvent) => Promise; @@ -39,9 +39,9 @@ export type GatewayClientOpts = { * Resolves when abortSignal fires (or a fatal misconfiguration throws). */ export async function runGatewayClient(opts: GatewayClientOpts): Promise { - const { relayBaseUrl, ofbKey, lane, onEvent, abortSignal, log = console.log, error = console.error } = opts; + const { relayBaseUrl, awadaKey, lane, onEvent, abortSignal, log = console.log, error = console.error } = opts; if (!relayBaseUrl) throw new Error("[awada] relayBaseUrl not configured"); - if (!ofbKey) throw new Error("[awada] ofbKey not configured"); + if (!awadaKey) throw new Error("[awada] awadaKey not configured"); const url = buildInboundWsUrl(relayBaseUrl, lane); let backoffIdx = 0; @@ -56,7 +56,7 @@ export async function runGatewayClient(opts: GatewayClientOpts): Promise { if (abortSignal?.aborted) break; const sessionStart = Date.now(); try { - await runOnce(url, ofbKey, lane, onEvent, abortSignal, log, error); + await runOnce(url, awadaKey, lane, onEvent, abortSignal, log, error); // runOnce resolves only on close/error; fall through to reconnect. } catch (err) { error(`[awada] gateway WS error: ${String(err)}`); @@ -90,7 +90,7 @@ function sleep(ms: number, abortSignal?: AbortSignal): Promise { */ function runOnce( url: string, - ofbKey: string, + awadaKey: string, lane: string, onEvent: (id: string, event: InboundEvent) => Promise, abortSignal: AbortSignal | undefined, @@ -99,7 +99,7 @@ function runOnce( ): Promise { return new Promise((resolve) => { let closed = false; - const ws = new WebSocket(url, { headers: { "X-OFB-Key": ofbKey } }); + const ws = new WebSocket(url, { headers: { "X-Awada-Key": awadaKey } }); let lastPong = Date.now(); const watchdog = setInterval(() => { @@ -114,8 +114,9 @@ function runOnce( } }, 30_000); - // ws auto-replies to protocol-level ping with pong; track pong events for liveness. - ws.on("pong", () => { + // Server sends protocol-level ping every 30s; ws auto-replies with pong. + // Track ping events for liveness (we receive pings, not pongs). + ws.on("ping", () => { lastPong = Date.now(); }); diff --git a/awada/src/types.ts b/awada/src/types.ts index 3b54c723..f3220bae 100644 --- a/awada/src/types.ts +++ b/awada/src/types.ts @@ -8,9 +8,8 @@ export type ResolvedAwadaAccount = { enabled: boolean; configured: boolean; relayBaseUrl?: string; - ofbKey?: string; + awadaKey?: string; lane: string; - platform?: string; config: AwadaConfig; }; diff --git a/crews/content-producer/AGENTS.md b/crews/content-producer/AGENTS.md index 79cf2f5d..26e63f42 100644 --- a/crews/content-producer/AGENTS.md +++ b/crews/content-producer/AGENTS.md @@ -14,7 +14,7 @@ ## 通用约定 -- **每接到一个活儿先建工作区**:视频类走 `output_videos//`(由 `video-producer` 内脚本建),平面设计类走 `design_assets/YYYY-MM-DD-<任务名>/`(由 `design-full init` 建) +- **每接到一个活儿先建工作区**:视频类走 `output_videos//`(由 `video-producer` 内脚本建);平台专家包委托并传入现成项目目录(`/outputs//`)时直接沿用,不另建;平面设计类走 `design_assets/YYYY-MM-DD-<任务名>/`(由 `design-full init` 建) - **Brief 确认前不得干活**:任何方向都先把需求整理成 brief,发用户确认后再进后续 - **成片/成稿交付前必跑自检**:视频走公共 `video-review`,平面设计走视觉 review(对照 brief + DESIGN.md) - **封面**:交付成片视频必须配含标题文字的封面图,走公共 `siliconflow-img-gen` diff --git a/crews/content-producer/openclaw_setting_sample.json b/crews/content-producer/openclaw_setting_sample.json index 61365ada..7a932a29 100644 --- a/crews/content-producer/openclaw_setting_sample.json +++ b/crews/content-producer/openclaw_setting_sample.json @@ -1,7 +1,36 @@ { + "id": "content-producer", + "name": "producer", + "workspace": "{path_to_.openclaw}/workspace-content-producer", "skills": [ - "video-producer" + "nano-pdf", + "skill-creator", + "session-logs", + "tmux", + "weather", + "summarize", + "gifgrep", + "aigc-video-gen", + "awk-tts", + "bgm-library", + "browser-guide", + "pexels-footage", + "pixabay-footage", + "siliconflow-img-gen", + "smart-search", + "video-review", + "wxwork-drive", + "video-producer", + "collage-broll", + "manim-explainer", + "design-full" ], + "subagents": { + "allowAgents": [ + "it-engineer", + "content-producer" + ] + }, "tools": { "exec": { "host": "gateway", @@ -9,10 +38,5 @@ "ask": "off" } }, - "subagents": { - "allowAgents": [ - "it-engineer", - "content-producer" - ] - } + "reasoningDefault": "off" } diff --git a/crews/content-producer/skills/collage-broll/SKILL.md b/crews/content-producer/skills/collage-broll/SKILL.md index 2b103648..a468f052 100644 --- a/crews/content-producer/skills/collage-broll/SKILL.md +++ b/crews/content-producer/skills/collage-broll/SKILL.md @@ -79,10 +79,10 @@ metadata: ## 默认项目目录 -路径契约——落在 `output_videos/` 下,名 ``: +路径契约——调用方指定了项目目录时(平台运营内容为 `/outputs//`)落在其下;否则落在 `output_videos/` 下,名 ``: ```text -output_videos// +/ # 即 /outputs// 或 output_videos// ├── brief.md # 文稿 + Gate 1 隐喻清单 ├── visual-spec.json # Gate 2 视觉规格 ├── imagegen-prompts.md # Gate 2 Seedream prompt 留档 @@ -301,7 +301,7 @@ aigc-video-gen --mode i2v \ `aigc-video-gen` 内部已带候选链 fallback(百炼 happyhorse-1.1-i2v 沿链 1.1 → 1.0 → wan2.7,百炼没配走火山 Seedance Fast → Normal → Mini)+ decisions.log 落盘,agent 只需逐条调度。 -如果出现 i2v 不收首尾帧的报错(`aigc-video-gen` 退出码非 0),检查 first-frame.png / last-frame.png 是否真存在、是否 720x1280——`aigc-video-gen` 要求相对路径在 `output_videos/` 下,**调用时 workdir 必须是 workspace 根**。 +如果出现 i2v 不收首尾帧的报错(`aigc-video-gen` 退出码非 0),检查 first-frame.png / last-frame.png 是否真存在、是否 720x1280——`aigc-video-gen` 要求相对路径在 `output_videos/` 或 `/outputs/` 下,**调用时 workdir 必须是 workspace 根**。 ### 4. 强制无声交付 diff --git a/crews/content-producer/skills/collage-broll/scripts/run_gate3.py b/crews/content-producer/skills/collage-broll/scripts/run_gate3.py index 4dc07255..746529e6 100644 --- a/crews/content-producer/skills/collage-broll/scripts/run_gate3.py +++ b/crews/content-producer/skills/collage-broll/scripts/run_gate3.py @@ -5,7 +5,7 @@ prompt aigc-video-gen --prompt(中文声画同出描述) first_frame 首帧路径(纯色空场,720x1280) last_frame 尾帧路径(确认静帧裁到 720x1280,720P) - output 输出 MP4 路径(相对 output_videos/,aigc-video-gen 的 ensure_safe_output 要求) + output 输出 MP4 路径(相对 workspace,须在 output_videos/ 或 /outputs/ 下——aigc-video-gen 的 ensure_safe_output 要求) ratio 默认 9:16 resolution 默认 720P duration 默认 5 diff --git a/crews/content-producer/skills/video-producer/SKILL.md b/crews/content-producer/skills/video-producer/SKILL.md index b330e99e..a0b8aa22 100644 --- a/crews/content-producer/skills/video-producer/SKILL.md +++ b/crews/content-producer/skills/video-producer/SKILL.md @@ -44,10 +44,10 @@ metadata: ## 工作区目录约定 -在 `output_videos/` 下建项目文件夹 `/`: +调用方传入了现成项目目录时(如平台专家包委托制作,传入 `/outputs//`,brief 已在其中)直接沿用;否则在 `output_videos/` 下建项目文件夹 `/`: ``` -output_videos// +/ # 即 /outputs// 或 output_videos// ├── brief.md # Stage 0/1 产出:意图路由 + 概念选项 + 用户选定 ├── reference-driven/ # Stage 1(可选,仅当 main 喂了 viral-chaser 报告) │ ├── viral-chaser-report.md # main 喂入的追爆报告原档(本技能不自己跑 viral-chaser) diff --git a/crews/content-producer/skills/video-producer/scripts/assemble.py b/crews/content-producer/skills/video-producer/scripts/assemble.py index c0d91089..7dc5e10d 100644 --- a/crews/content-producer/skills/video-producer/scripts/assemble.py +++ b/crews/content-producer/skills/video-producer/scripts/assemble.py @@ -281,7 +281,7 @@ def concat_xfade(segments: list[tuple[str, Path, float]], out: Path, transition: def main() -> None: parser = argparse.ArgumentParser(description="Stage 12 assemble 按序拼接") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--source-dir", default=None, help="段目录(默认 render/;timeline-compose 调时传 artifacts/timeline/)") parser.add_argument("--output", default="video.mp4", help="输出名(相对 project_dir,默认 video.mp4)") parser.add_argument("--transition", default="hard", choices=sorted(VALID_TRANSITIONS)) diff --git a/crews/content-producer/skills/video-producer/scripts/asset-resolve.py b/crews/content-producer/skills/video-producer/scripts/asset-resolve.py index ca7d789c..fc8d21c8 100644 --- a/crews/content-producer/skills/video-producer/scripts/asset-resolve.py +++ b/crews/content-producer/skills/video-producer/scripts/asset-resolve.py @@ -37,7 +37,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 8 asset-resolve") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--source", default="both", choices=["pexels", "pixabay", "both"]) parser.add_argument("--no-confirm", action="store_true", help="agent 已人核完毕,不再呈交") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/character-register.py b/crews/content-producer/skills/video-producer/scripts/character-register.py index b9a104fa..f861e360 100644 --- a/crews/content-producer/skills/video-producer/scripts/character-register.py +++ b/crews/content-producer/skills/video-producer/scripts/character-register.py @@ -29,7 +29,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 6 character-register") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/delivery-promise-lock.py b/crews/content-producer/skills/video-producer/scripts/delivery-promise-lock.py index 6f86a202..637bf68b 100644 --- a/crews/content-producer/skills/video-producer/scripts/delivery-promise-lock.py +++ b/crews/content-producer/skills/video-producer/scripts/delivery-promise-lock.py @@ -33,7 +33,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 9b delivery-promise-lock") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/intent-router.py b/crews/content-producer/skills/video-producer/scripts/intent-router.py index 0cb294c1..0415762f 100644 --- a/crews/content-producer/skills/video-producer/scripts/intent-router.py +++ b/crews/content-producer/skills/video-producer/scripts/intent-router.py @@ -9,7 +9,7 @@ Usage: python3 scripts/intent-router.py [--user-text "..."] [--report-file path] -入:project_dir(output_videos//),可选用户原文或 viral-chaser 报告路径 +入:project_dir(output_videos// 或平台运营目录 /outputs//),可选用户原文或 viral-chaser 报告路径 出:project_dir/script/intent.json(档位 + 主题 + 受众 + 时长目标 + 备选 + 决策理由) 产物文件存在性即 checkpoint:intent.json 已存在则打印现状退出,不重生成(用户手改后续跑)。 @@ -44,7 +44,7 @@ def detect_genre(text: str) -> tuple[str, str]: def main() -> None: parser = argparse.ArgumentParser(description="Stage 0 intent-router") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--user-text", default=None, help="用户原文") parser.add_argument("--report-file", default=None, help="main 喂入的 viral-chaser 报告路径(可选)") parser.add_argument("--genre", default=None, choices=sorted(VALID_GENRES), help="强制档位,跳过自动判定") diff --git a/crews/content-producer/skills/video-producer/scripts/make-cover.py b/crews/content-producer/skills/video-producer/scripts/make-cover.py index 616e46ef..67e0f98d 100644 --- a/crews/content-producer/skills/video-producer/scripts/make-cover.py +++ b/crews/content-producer/skills/video-producer/scripts/make-cover.py @@ -24,7 +24,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 14a make-cover") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--title", default=None, help="封面标题文字,不传则从 brief.md 抽") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/make-outro.py b/crews/content-producer/skills/video-producer/scripts/make-outro.py index 1c667e76..af27922a 100644 --- a/crews/content-producer/skills/video-producer/scripts/make-outro.py +++ b/crews/content-producer/skills/video-producer/scripts/make-outro.py @@ -77,7 +77,7 @@ def hex_to_ffmpeg_color(hex_color: str) -> str: def main() -> None: parser = argparse.ArgumentParser(description="make-outro 片尾制作") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--image", required=True, help="形象图路径(PNG/JPG)") parser.add_argument("--slogan", required=True, help="slogan 文本(烧录到画面中央)") parser.add_argument("--color", default=None, help="颜色配置 JSON 路径") diff --git a/crews/content-producer/skills/video-producer/scripts/mix-audio.py b/crews/content-producer/skills/video-producer/scripts/mix-audio.py index 4dd32cbf..ffcf20a0 100644 --- a/crews/content-producer/skills/video-producer/scripts/mix-audio.py +++ b/crews/content-producer/skills/video-producer/scripts/mix-audio.py @@ -40,7 +40,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 11 mix-audio") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/motion-audit.py b/crews/content-producer/skills/video-producer/scripts/motion-audit.py index f783e4b4..beabb671 100644 --- a/crews/content-producer/skills/video-producer/scripts/motion-audit.py +++ b/crews/content-producer/skills/video-producer/scripts/motion-audit.py @@ -32,7 +32,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 13b motion-audit") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/narration-align.py b/crews/content-producer/skills/video-producer/scripts/narration-align.py index f97fed6e..51362d02 100644 --- a/crews/content-producer/skills/video-producer/scripts/narration-align.py +++ b/crews/content-producer/skills/video-producer/scripts/narration-align.py @@ -154,7 +154,7 @@ def main() -> None: load_env_file() parser = argparse.ArgumentParser(description="Stage 11b narration-align") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument( "--audio", default=None, diff --git a/crews/content-producer/skills/video-producer/scripts/reference-concepts.py b/crews/content-producer/skills/video-producer/scripts/reference-concepts.py index 7fef0de3..1e97f0eb 100644 --- a/crews/content-producer/skills/video-producer/scripts/reference-concepts.py +++ b/crews/content-producer/skills/video-producer/scripts/reference-concepts.py @@ -6,7 +6,7 @@ Usage: python3 scripts/reference-concepts.py --report-file path -入:project_dir(output_videos//)+ viral-chaser 报告路径 +入:project_dir(output_videos// 或平台运营目录 /outputs//)+ viral-chaser 报告路径 出:project_dir/reference-driven/concepts.md(2–3 差异化概念 + 成本 + 备选路径) 无报告则跳过本阶段,agent 直入 Stage 2 story-develop(本脚本不报错退出)。 @@ -18,7 +18,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 1 reference-concepts") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--report-file", default=None, help="main 喂入的 viral-chaser 报告路径") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/render-shot.py b/crews/content-producer/skills/video-producer/scripts/render-shot.py index a105783b..10e9674d 100644 --- a/crews/content-producer/skills/video-producer/scripts/render-shot.py +++ b/crews/content-producer/skills/video-producer/scripts/render-shot.py @@ -30,7 +30,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 10 render-shot") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--shot-id", default=None, help="只渲某镜,不传则提示 agent 逐镜跑") parser.add_argument("--dry-run", action="store_true", help="只打印调用计划不真渲") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/scene-compose.py b/crews/content-producer/skills/video-producer/scripts/scene-compose.py index 71b7c14d..efab8f26 100644 --- a/crews/content-producer/skills/video-producer/scripts/scene-compose.py +++ b/crews/content-producer/skills/video-producer/scripts/scene-compose.py @@ -70,7 +70,7 @@ def run(cmd: list[str]) -> subprocess.CompletedProcess: def main() -> None: parser = argparse.ArgumentParser(description="scene-compose 单 Scene 分段合成") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--scene", required=True, help="Scene JSON 路径(相对 project_dir 或绝对)") parser.add_argument("--output", default=None, help="输出 Scene 片段名(相对 project_dir;默认从 scene JSON 推导)") diff --git a/crews/content-producer/skills/video-producer/scripts/script-self-eval.py b/crews/content-producer/skills/video-producer/scripts/script-self-eval.py index c61a55b4..930aa5d3 100644 --- a/crews/content-producer/skills/video-producer/scripts/script-self-eval.py +++ b/crews/content-producer/skills/video-producer/scripts/script-self-eval.py @@ -35,7 +35,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 3b script-self-eval") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/script-write.py b/crews/content-producer/skills/video-producer/scripts/script-write.py index 3cd8b723..e4332b23 100644 --- a/crews/content-producer/skills/video-producer/scripts/script-write.py +++ b/crews/content-producer/skills/video-producer/scripts/script-write.py @@ -23,7 +23,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 3 script-write") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/shot-decompose.py b/crews/content-producer/skills/video-producer/scripts/shot-decompose.py index abab5a4e..cc688eab 100644 --- a/crews/content-producer/skills/video-producer/scripts/shot-decompose.py +++ b/crews/content-producer/skills/video-producer/scripts/shot-decompose.py @@ -25,7 +25,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 5 shot-decompose") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/slideshow-risk.py b/crews/content-producer/skills/video-producer/scripts/slideshow-risk.py index bd7ab312..ecdd4781 100644 --- a/crews/content-producer/skills/video-producer/scripts/slideshow-risk.py +++ b/crews/content-producer/skills/video-producer/scripts/slideshow-risk.py @@ -36,7 +36,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 9a slideshow-risk") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/slot-plan.py b/crews/content-producer/skills/video-producer/scripts/slot-plan.py index 46deea03..e573ab20 100644 --- a/crews/content-producer/skills/video-producer/scripts/slot-plan.py +++ b/crews/content-producer/skills/video-producer/scripts/slot-plan.py @@ -35,7 +35,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 7 slot-plan") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--tone", default=None, choices=sorted(TONE_SLOT_TABLE), help="调性,不传走 narrative 默认") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/story-develop.py b/crews/content-producer/skills/video-producer/scripts/story-develop.py index 96309f5a..0872e806 100644 --- a/crews/content-producer/skills/video-producer/scripts/story-develop.py +++ b/crews/content-producer/skills/video-producer/scripts/story-develop.py @@ -18,7 +18,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 2 story-develop") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/storyboard-build.py b/crews/content-producer/skills/video-producer/scripts/storyboard-build.py index 12fed9df..4763fe3b 100644 --- a/crews/content-producer/skills/video-producer/scripts/storyboard-build.py +++ b/crews/content-producer/skills/video-producer/scripts/storyboard-build.py @@ -24,7 +24,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 4 storyboard-build") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/timeline-compose.py b/crews/content-producer/skills/video-producer/scripts/timeline-compose.py index 2f00d68a..1af6a4f6 100644 --- a/crews/content-producer/skills/video-producer/scripts/timeline-compose.py +++ b/crews/content-producer/skills/video-producer/scripts/timeline-compose.py @@ -74,7 +74,7 @@ def probe_duration(path: Path) -> float: def main() -> None: parser = argparse.ArgumentParser(description="timeline-compose 时间轴合成") - parser.add_argument("project_dir", help="output_videos//") + parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") parser.add_argument("--timeline", required=True, help="时间轴 JSON 路径(相对 project_dir 或绝对)") parser.add_argument("--output", default="video.mp4", help="输出合成片段名(相对 project_dir,默认 video.mp4)") parser.add_argument("--transition", default="hard", choices=["hard", "fade", "dissolve", "xfade"]) diff --git a/crews/it-engineer/DENIED_SKILLS b/crews/it-engineer/DENIED_SKILLS index fd10b581..13f13490 100644 --- a/crews/it-engineer/DENIED_SKILLS +++ b/crews/it-engineer/DENIED_SKILLS @@ -8,3 +8,4 @@ siliconflow-img-gen awk-tts aigc-video-gen gifgrep +bgm-library diff --git a/crews/it-engineer/MEMORY.md b/crews/it-engineer/MEMORY.md index 877f6281..603314b4 100644 --- a/crews/it-engineer/MEMORY.md +++ b/crews/it-engineer/MEMORY.md @@ -13,14 +13,6 @@ 内置技能所需依赖已经在部署环境中。 -#### awada 插件依赖(ws + zod) - -- **Docker 部署**:Dockerfile wiseflow-layer 阶段 `COPY awada/ + npm install --omit=dev`,ws+zod 烘进 `/opt/openclaw/awada/node_modules`。 -- **源码部署**:`apply-addons.sh` 自动 `cd awada && npm install --omit=dev`(哈希守卫 `.awada-pkg-hash`,幂等)。 -- **关键点**:awada 插件运行时从自身 `awada/node_modules` 解析 ws/zod,**不**走 `~/.openclaw/node_modules`(不在向上解析链),故必须装在 awada 局部,不能靠统一依赖扫描。 -- **Phase 4 已完成**(2026-07-07):awada 改 HTTP/WS transport 调 relay 网关,ioredis 已从 deps 移除,预装步骤改装 ws+zod。proactive-send skill 同步迁 HTTP 网关,不再依赖 ioredis。 -- **IT engineer 介入时机**:仅当日志报 `Cannot find module 'ws'`(plugin=awada)且上述预装漏跑时,按 `awada-channel-setup` SKILL 步骤 1 手动补装。 - #### volume 扩展 - 用户额外装 skill 时的依赖路径: @@ -33,11 +25,11 @@ - **何时装**: - skill 报错 `ModuleNotFoundError: No module named 'xxx'` → 装 xxx 到该 skill 的 vendor - skill 报错 `Cannot find module 'xxx'`(Node)→ 装 xxx 到该 skill 局部 node_modules -- **何时**不**装**: - - skill 内 import 但镜像已预装(按镜像预装策略) → 检查 image 是否完整 / 用户是否漏装 +- **何时不装**: + - skill 内 import 但镜像已预装 → 检查 image 是否完整 / 用户是否漏装 - 通用依赖(如 requests、Pillow)应已在镜像,不需用户装 - **依赖冲突处理**: - - **Python**:vendor 目录是隔离的(每个 skill 独立),不冲突;如果跨 skill 同名不同版本需求 → 各自装各自的 vendor + - **Python**:vendor 目录是隔离的(每个 skill 独立),不冲突;跨 skill 同名不同版本需求 → 各自装各自的 vendor - **Node**:局部 node_modules 可能与全局 openclaw 依赖冲突 → 用 `npm install --save-prefix=~` 避免锁到特定 patch 版本 - **it-engineer 介入**: - 用户报告"skill 不能用" → 1)查 `~/.openclaw/logs/gateway-error.log` 2)确认 `pip list --target ~/.openclaw/skills//vendor/` 或 `ls ~/.openclaw/skills//node_modules/` 3)按需装 @@ -48,66 +40,26 @@ ### camoufox-cli 排故 -- **指纹模板 bake**(Docker 镜像内):`/root/.openclaw/logins/_template/camoufox-cli.json`,由 `Dockerfile wiseflow-layer` 阶段跑 `camoufox-cli --session _template --persistent open about:blank`(默认 headless)生成。 +- **指纹模板 bake**(Docker 镜像内):`/root/.openclaw/logins/_template/camoufox-cli.json`,由 `Dockerfile wiseflow-layer` 阶段跑 `camoufox-cli --session _template --persistent --headless open about:blank` 生成。 - **运行时模板复用**:每个 agent session 启动前 `cp /root/.openclaw/logins/_template/camoufox-cli.json ~/.camoufox-cli/profiles//`。 - **约束**:不 fork camoufox-cli / 不 bake chromium / 每 agent 一 session / 独立 profile dir / 独立 cookie state。 - **常见问题**: - `camoufox-cli open` 超时 → `camoufox-cli close --all` 清残留 + 重试 - - `qr-confirm` 轮询不到成功 → 用户手机上确认后再说;不要盲等超过 `--timeout`(默认 180s) - `cookie-import` 后访问仍 401 → cookies 过期 / 域不匹配;重新走登录流 - daemon 残留 → `camoufox-cli close --all` 兜底;每任务结束必须 `session-cleanup` + - **⚠️ camoufox-cli 二进制误报缺失**:`camoufox-js` 的 `camoufoxPath()` 只检查 `version.json` 不检查二进制文件本身。`camoufox-cli install` 会误报 "already up to date"。排查时必须用 `ls -la ~/.cache/camoufox/camoufox-bin` + `file` + 实际 `open` 测试确认,不要只看 install 输出。 -### 运行数据中openclaw.json中禁止更改的项目 - -如下`openclaw.json`中的项目严格禁止更改,如果如果用户明确要求更改,你也应向他解释理由,并再三征得确认: - -- browser模块:本系统已经对浏览器的使用做过优化,默认会使用camoufox-cli,Browser tool是作为托底手段去处理反爬特别严格的站点,openclaw.json中整个browser部分的配置已经是针对这种场景下的最佳配置。 -- **channel 字段层位(易手撸出错,重点遵守)**:`channel` 只允许出现在两个层位——`bindings[].match.channel`(路由层)和 `channels.`(通道配置层)。**禁止在任何 agent 顶层对象(`agents.list[]` 内某个 crew 的对象)上添加 `channel` 字段**。这一条覆盖全部 crew:把 `sales-cs` / `content-producer` 等 crew 加入 `agents.list` 时,对象里只有 `id` / `name` / `subagents` / `heartbeat` / `tools` 等来自各自 sample 的字段,**绝不写 `"channel": "wecom"` / `"feishu"` / `"awada"`**。绑 channel 一律走对应 skill 的 apply 脚本(`awada-channel-setup` / `work-channel-binding`),它会把 channel 写进正确的 `bindings` + `channels` + `plugins` 三处;不得手贴 agent 块。 - -### LLM 模型参数约束(火山方舟 awk provider) - -#### GLM 5.2 系列(模型 id `glm-latest` / 其他 GLM 5.2 变体) - -- **Provider 端点**:`https://ark.cn-beijing.volces.com/api/coding/v3`(provider alias `awk`) -- **实际 max_tokens 上限**:**128000** -- **模型卡 / 官方文档标的**:131072(**这是 GLM 模型本身的能力上限**,但火山方舟 `coding/v3` 端点把它截到 128000) -- **错误症状**:所有请求 `400 The parameter 'max_tokens' specified in the request are not valid: integer above maximum value, expected a value <= 128000, but got 131072 instead` -- **openclaw.json 正确配置**:`maxTokens: 128000` - -### 某个 agent 全报 "Something went wrong"处置方案 - -1. **看 gateway-error.log**(`/home/wukong/wiseflow-pro/logs/gateway-error.log`),找 `embedded run agent end: isError=true` + 紧跟的 `error=LLM request failed` 行。 -2. **看 sessions.json 里那个 agent 的 modelOverride**(`~/.openclaw/agents//sessions/sessions.json`,key 是 `agent::feishu:direct:`): - - 如果有 `modelOverride` + `modelOverrideSource: "user"` → 说明之前 `/model ` 把会话锁死在那个模型上了。 - - **修复**:用户在该 agent 对话里发 `/model <默认主模型>`,或 IT Engineer 删掉 sessions.json 里的 `modelOverride/providerOverride/modelOverrideSource` 三个字段(注意先 `cp` 备份 `.bak-<日期>`)。 - - **原因**:用户用 `/model` 切换是持久化的,`/new` 不会清。 -3. **如果错误是 `max_tokens` 超过 provider 上限**:改 `openclaw.json` 里那个模型的 `maxTokens`。注意备份,hot-reload 通常生效。 +### openclaw.json 禁止更改的项目 -### ⚠️ 重大警告:`pnpm openclaw ` 会触发 build,在运行中的 Gateway 上使用会掏空系统 +- **browser 模块**:本系统已对浏览器使用做过优化,默认使用 camoufox-cli,Browser tool 是托底手段(处理反爬特别严格的站点),整个 browser 部分配置已是该场景下的最佳配置。即使用户明确要求更改,也应解释理由并再三征得确认。 -**防范规则(勿犯)**: +### ⚠️ 控制面操作铁律:走 MCP 工具,绝不走 `pnpm openclaw` CLI -1. **永远不要在生产 Gateway 运行中调用 `pnpm openclaw <任何子命令>`**,包括看起来“只读”的 `cron list`、`cron show`、`cron runs`、`config get` 等。只要走 `pnpm openclaw` 入口,都会触发 build。 -2. **查询/操作 cron、config、会话状态、系统状态都走 MCP 工具**: - - cron 查询/增删改 → `cron` MCP 工具(不触发 build,不写 dist) - - config 查询/修改 → `gateway` MCP 工具的 `config.get` / `config.patch` / `config.apply` - - 会话状态 → `sessions_list` / `sessions_history` / `session_status` -3. **如果实在需要走 CLI**:优先看项目里的 `dist/` 是否已 build 过且是热的,可以直接 `node dist/index.js ` 跳过 npm script 的 build wrapper;但在生产上也不推荐。 -4. **对话里跟用户呈现任何 cron/config 信息**:走 MCP 工具拿到结果。 +生产 Gateway 运行中,**任何** `pnpm openclaw <子命令>`(包括看似只读的 `cron list` / `cron show` / `cron runs` / `config get` / `doctor --fix`)都会触发重新 build、写运行中 Gateway 共享的 `dist/`,多次连续调用可能导致系统崩溃。这是唯一硬性禁止。 -**反例(禁止)**: -```bash -cd /home/wukong/wiseflow-pro/openclaw && pnpm openclaw cron list -cd /home/wukong/wiseflow-pro/openclaw && pnpm openclaw config get -cd /home/wukong/wiseflow-pro/openclaw && pnpm openclaw doctor --fix -# 以上三条都会触发 build,都是雷区。 -``` +**原则(一句话)**:控制面工具(`cron` / `gateway` / `nodes`)**可用就用 MCP 工具**;**不可用**(聊天渠道被 owner-only deny 移除)就**直接操作 SQLite**,不绕弯、无需征求许可。 -**正例(推荐)**: -``` -MCP cron 工具调用,action="list" / "get" / "add" / "update" / "remove" / "run" / "runs" -MCP gateway 工具调用,action="config.get" / "config.patch" / "config.apply" / "restart" -``` +**工具映射表**: | 需求 | 工具 | |------|------| @@ -117,45 +69,20 @@ MCP gateway 工具调用,action="config.get" / "config.patch" / "config.apply" | 节点 / 文件传输 / 调用 | `nodes` / `file_fetch` / `file_write` / `dir_list` / `dir_fetch` | | 技能架库 增删改查 | `skill_workshop` | -### ⚠️ OpenClaw binding routing 的关键坑 - -**坑 1:binding 不写 `accountId` 时不会"通配所有 account"** — `src/routing/resolve-route.ts` 里的 `normalizeBindingMatch` 把没填的 `match.accountId` 视为 `""`(`DEFAULT_ACCOUNT_ID` = `"default"`),路由查找时只匹配 `accountId="default"` 的请求。**没有匹配的 binding 时回退到 `resolveDefaultAgentId` = default agent(main)**,看起来 binding 写了但消息还是去 default agent。 - -**正确做法**(任何 binding 改 channel 路由都要写 `accountId`): -- 想通配所有 account:用 `"accountId": "*"`(会进 `byAnyAccount` 桶) -- 想精确匹配某个 account:用具体 account id - -**坑 2:routing 缓存 `resolvedRouteCacheByCfg` 不会因 SIGUSR1 hot-reload 重置** — 它是 `WeakMap`,基于 cfg 对象引用判断,hot-reload 不换 cfg 引用所以不重置。改 binding 后必须用 `systemctl --user restart openclaw-gateway.service` 完整重启(这会断所有 session,因此执行前必须告知用户并征得同意)。 - -**坑 3:sessions.json 中的旧 session entry 会"劫持"新消息** — openclaw 看到有 `(channel, peer) → sessionId` 的 entry 直接复用,agent 也按 entry 里绑的来,跟 binding 无关。改 binding 之前/之后都要查 `agents//sessions/sessions.json` 把这个 entry 删掉,否则即使 binding 改对了,session 缓存仍把消息路由回旧 agent。 - -### 定时任务(Cron)维护方案 - -> **v2026.6.6 起**:cron 存储已从 JSON 文件迁移至 SQLite,**禁止再编辑任何 JSON 文件**。 +### 定时任务(Cron)维护方案 -#### 存储变更 +> **v2026.6.6 起**:cron 存储已从 JSON 迁移至 SQLite(`~/.openclaw/state/openclaw.sqlite`,表 `cron_jobs` / `cron_run_logs`)。**禁止编辑 `~/.openclaw/cron/` 下任何 JSON**(已废弃,运行时不再读取;旧文件已由 `doctor --fix` 迁移为 `.migrated`,可安全删除)。 -| 项目 | 旧方案(已废弃) | 新方案(当前) | -|------|------------------|----------------| -| Job 定义 | `~/.openclaw/cron/jobs.json` | SQLite 表 `cron_jobs` | -| 运行时状态 | `~/.openclaw/cron/jobs-state.json` | SQLite 同表内字段 | -| 运行日志 | `~/.openclaw/cron/runs/*.jsonl` | SQLite 表 `cron_run_logs` | -| 数据库位置 | - | `~/.openclaw/state/openclaw.sqlite` | +**操作原则**:`cron` MCP 工具可用 → 用工具;不可用 → 直接操作 SQLite。修改即时生效(跑在 Gateway 进程内),无需重启。绝不走 `pnpm openclaw` / `node dist/index.js` CLI。 -旧文件已被 `doctor --fix`(上游升级后一次性迁移,由用户手动调用)重命名为 `.migrated` 后缀,数据已导入 SQLite。`.migrated` 文件可安全删除。 - -> ⚠️ **生产 Gateway 运行中,不得调用 `pnpm openclaw cron ...` / `node dist/index.js cron ...` 任何 CLI 入口**。它会触发重新 build 并写运行中 Gateway 共享的 `dist/`,多次连续调用可能导致系统崩溃。一律走 MCP `cron` 工具。 - -**正确姿势(MCP `cron` 工具,零 build、零 dist 写入)**: +#### 方式一:MCP cron 工具(首选) ``` -# 查看所有定时任务 +# 查看所有任务 / 任务详情 cron(action="list") - -# 查看某个任务详情 cron(action="get", jobId="") -# 新增定时任务(完整 schema 见工具描述:schedule 、payload、delivery、sessionTarget 等) +# 新增任务(完整 schema 见工具描述:schedule、payload、delivery、sessionTarget 等) cron(action="add", job={ "name": "任务名", "agentId": "", @@ -169,53 +96,100 @@ cron(action="add", job={ cron(action="update", jobId="", patch={"enabled": true}) cron(action="update", jobId="", patch={"enabled": false}) -# 修改投递目标 -cron(action="update", jobId="", patch={ - "delivery": {"mode": "announce", "channel": "feishu", "to": "user:ou_xxx"} -}) +# 修改投递目标 / 模型覆盖 +cron(action="update", jobId="", patch={"delivery": {"mode": "announce", "channel": "feishu", "to": "user:ou_xxx"}}) +cron(action="update", jobId="", patch={"payload": {"model": "provider/model"}}) -# 修改模型覆盖 -cron(action="update", jobId="", patch={ - "payload": {"model": "provider/model"} -}) - -# 删除任务 +# 删除任务 / 手动触发(runMode="force" 立即触发)/ 查看运行历史 cron(action="remove", jobId="") - -# 手动触发一次(默认仅在到期时走,加 runMode="force" 立刻触发) cron(action="run", jobId="", runMode="force") - -# 查看运行历史 cron(action="runs", jobId="", limit=20) ``` -#### 直接查询 SQLite(只读排查用) +#### 方式二:直接操作 SQLite(工具不可用时) + +**通用步骤(每次修改必走)**: + +1. **查**:先确认目标 job +2. **备份**:受影响行存 /tmp +3. **改**:SQL 必须**同时更新「结构化列」和「job_json 快照」**(两处不一致会打架:MCP 工具读列、运行时读 job_json) +4. **验证**:改完立刻回读,确认两处都已生效 + +```bash +# 1. 查 +sqlite3 ~/.openclaw/state/openclaw.sqlite "SELECT job_id, name, enabled, payload_model, payload_fallbacks_json FROM cron_jobs;" + +# 2. 备份 +sqlite3 ~/.openclaw/state/openclaw.sqlite "SELECT job_id, name, payload_model, payload_fallbacks_json, job_json FROM cron_jobs WHERE job_id='';" > /tmp/cron_bak_$(date +%Y%m%d-%H%M%S).txt +``` + +```sql +-- 3. 改(常用操作,2026-08-22 实操验证) + +-- 清空 fallback(让任务继承 openclaw.json 的 agents.defaults.model) +UPDATE cron_jobs SET payload_fallbacks_json = NULL, job_json = json_remove(job_json, '$.payload.fallbacks') WHERE job_id = ''; + +-- 设置 fallback +UPDATE cron_jobs SET payload_fallbacks_json = '["ark/deepseek-v4-flash"]', job_json = json_set(job_json, '$.payload.fallbacks', json('["ark/deepseek-v4-flash"]')) WHERE job_id = ''; -SQLite 只读查询不会触发 build,安全。但 **不得直接 UPDATE/INSERT/DELETE `cron_jobs` 表**,会跟 MCP 的状态机、`job_json` 冲突。 +-- 指定任务模型 +UPDATE cron_jobs SET payload_model = 'bailian/deepseek-v4-flash-0731', job_json = json_set(job_json, '$.payload.model', 'bailian/deepseek-v4-flash-0731') WHERE job_id = ''; + +-- 禁用 / 启用 +UPDATE cron_jobs SET enabled = 0, job_json = json_set(job_json, '$.enabled', false) WHERE job_id = ''; +``` ```bash -# 列出所有 job 及关键字段 -sqlite3 ~/.openclaw/state/openclaw.sqlite \ - "SELECT job_id, name, schedule_expr, enabled, delivery_mode, delivery_channel, delivery_to FROM cron_jobs;" +# 4. 验证(回读确认列与 job_json 两处一致) +sqlite3 ~/.openclaw/state/openclaw.sqlite "SELECT job_id, name, enabled, payload_model, payload_fallbacks_json, job_json FROM cron_jobs WHERE job_id='';" -# 查看最近运行记录 -sqlite3 ~/.openclaw/state/openclaw.sqlite \ - "SELECT job_id, seq, datetime(ts/1000, 'unixepoch', 'localtime') as time, status, error FROM cron_run_logs ORDER BY ts DESC LIMIT 20;" +# 只读排查(任何时候都安全):列出所有 job 关键字段 / 最近运行记录 +sqlite3 ~/.openclaw/state/openclaw.sqlite "SELECT job_id, name, schedule_expr, enabled, delivery_mode, delivery_channel, delivery_to FROM cron_jobs;" +sqlite3 ~/.openclaw/state/openclaw.sqlite "SELECT job_id, seq, datetime(ts/1000, 'unixepoch', 'localtime') as time, status, error FROM cron_run_logs ORDER BY ts DESC LIMIT 20;" ``` -修改、增删都走 MCP `cron` 工具,**不允许手工 SQL 修改这些表**。 +**避坑**: +- 只读 SQLite 查询任何时候都安全(不触发 build);**修改**仅在 cron/gateway 工具不可用时才走 SQL +- 不要手工改 `cron_run_logs` 表(运行历史,只读查询可以) +- cron 修改即时生效,无需重启 -#### 迁移操作(如需要) +### ⚠️ OpenClaw binding routing 的关键坑 -1. 读取`~/.openclaw/cron/job.json` 文件,获取目前的定时任务配置。 -2. 按上使用`cron`MCP工具进行配置。 +**坑 1:binding 不写 `accountId` 时不会"通配所有 account"** — `src/routing/resolve-route.ts` 的 `normalizeBindingMatch` 把没填的 `match.accountId` 视为 `""`(`DEFAULT_ACCOUNT_ID` = `"default"`),路由查找只匹配 `accountId="default"` 的请求。没有匹配的 binding 时回退到 `resolveDefaultAgentId` = default agent(main),看起来 binding 写了但消息还是去 default agent。 -#### 重要提醒 +**正确做法**(任何 binding 改 channel 路由都要写 `accountId`): +- 想通配所有 account → `"accountId": "*"`(会进 `byAnyAccount` 桶) +- 想精确匹配某个 account → 用具体 account id -1. **禁止手动编辑** `~/.openclaw/cron/` 下的任何 JSON 文件,它们已不再被运行时读取 -2. **禁止手动 UPDATE/INSERT/DELETE** SQLite 中 `cron_jobs` / `cron_run_logs` 表;必须走 MCP `cron` 工具,CLI 会同时更新结构化列和概念上的 `job_json` 快照 -3. cron 运行在 Gateway 进程内,修改后立即生效,无需重启 +**坑 2:routing 缓存 `resolvedRouteCacheByCfg` 不会因 SIGUSR1 hot-reload 重置** — 它是 `WeakMap`,基于 cfg 对象引用判断,hot-reload 不换 cfg 引用所以不重置。改 binding 后必须用 `systemctl --user restart openclaw-gateway.service` 完整重启(这会断所有 session,执行前必须告知用户并征得同意)。 + +**坑 3:sessions.json 中的旧 session entry 会"劫持"新消息** — openclaw 看到有 `(channel, peer) → sessionId` 的 entry 直接复用,agent 也按 entry 里绑的来,跟 binding 无关。改 binding 之前/之后都要查 `agents//sessions/sessions.json` 把这个 entry 删掉,否则即使 binding 改对了,session 缓存仍把消息路由回旧 agent。 + +--- + +#### awada 插件依赖(ws + zod) + +- **Docker 部署**:Dockerfile wiseflow-layer 阶段 `COPY awada/ + npm install --omit=dev`,ws+zod 烘进 `/opt/openclaw/awada/node_modules`。 +- **源码部署**:`apply-addons.sh` 自动 `cd awada && npm install --omit=dev`(哈希守卫 `.awada-pkg-hash`,幂等)。 +- **关键点**:awada 插件运行时从自身 `awada/node_modules` 解析 ws/zod,**不**走 `~/.openclaw/node_modules`(不在向上解析链),故必须装在 awada 局部,不能靠统一依赖扫描。 +- **Phase 4 已完成**(2026-07-07):awada 改 HTTP/WS transport 调 relay 网关,ioredis 已从 deps 移除,预装步骤改装 ws+zod。proactive-send skill 同步迁 HTTP 网关,不再依赖 ioredis。 +- **IT engineer 介入时机**:仅当日志报 `Cannot find module 'ws'`(plugin=awada)且上述预装漏跑时,按 `awada-channel-setup` SKILL 步骤 1 手动补装。 --- ## 运行中持续积累的经验 + +### exec-approvals.json 修改经验 +- **不要直接修改 `exec-approvals.json`**:该文件由 `setup-crew.sh` 自动生成,每次升级或执行脚本时会被覆盖。 +- **正确做法**:在对应 agent 的 workspace 下创建/修改 `ALLOWED_COMMANDS` 文件,格式参考 `workspace-sales-cs/ALLOWED_COMMANDS`。 +- **文件格式**:每行 `+./skills/xxx/scripts/xxx.sh`,相对于 workspace 根目录。 +- **同步命令**:修改后执行 `cd && ./scripts/setup-crew.sh` 使配置生效。 + +### 某个 agent 全报 "Something went wrong"处置方案 + +1. **看 gateway-error.log**(`/home/wukong/wiseflow-pro/logs/gateway-error.log`),找 `embedded run agent end: isError=true` + 紧跟的 `error=LLM request failed` 行。 +2. **看 sessions.json 里那个 agent 的 modelOverride**(`~/.openclaw/agents//sessions/sessions.json`,key 是 `agent::feishu:direct:`): + - 如果有 `modelOverride` + `modelOverrideSource: "user"` → 说明之前 `/model ` 把会话锁死在那个模型上了。 + - **修复**:用户在该 agent 对话里发 `/model <默认主模型>`,或 IT Engineer 删掉 sessions.json 里的 `modelOverride/providerOverride/modelOverrideSource` 三个字段(注意先 `cp` 备份 `.bak-<日期>`)。 + - **原因**:用户用 `/model` 切换是持久化的,`/new` 不会清。 +3. **如果错误是 `max_tokens` 超过 provider 上限**:改 `openclaw.json` 里那个模型的 `maxTokens`。注意备份,hot-reload 通常生效。 diff --git a/crews/it-engineer/TOOLS.md b/crews/it-engineer/TOOLS.md index d611914d..d4e70634 100644 --- a/crews/it-engineer/TOOLS.md +++ b/crews/it-engineer/TOOLS.md @@ -1,10 +1,8 @@ # IT Engineer Agent — Tools -### 运行环境铁律:OpenClaw 控制面操作 **一律走 MCP 工具**,不跳 CLI +## 控制面操作速查(完整规则见 MEMORY.md「控制面操作铁律」) -直接使用CLI会触发写运行中 Gateway 共享的 `dist/`,极端情况下导致系统崩坏。 - -**铁律**:生产 Gateway 运行中,以下操作全部走 MCP 工具,**不允许走 `pnpm openclaw` / `node dist/index.js` 任何 CLI 入口**: +**原则**:控制面工具(`cron` / `gateway` / `nodes`)**可用就用 MCP 工具**;**不可用**(聊天渠道 owner-only deny 移除)就**直接操作 SQLite**(方法见 MEMORY.md)。**绝不走 `pnpm openclaw` / `node dist/index.js` 任何 CLI 入口**——会触发 build 写运行中 Gateway 共享的 `dist/`,极端情况崩系统,连看似只读的 `cron list` / `config get` 也是雷区。 | 需求 | 工具 | |------|------| @@ -14,19 +12,21 @@ | 节点 / 文件传输 / 调用 | `nodes` / `file_fetch` / `file_write` / `dir_list` / `dir_fetch` | | 技能架库 增删改查 | `skill_workshop` | -看起来“只读”的 `pnpm openclaw cron list / cron show / cron runs / config get` 同样会触发 build,**同样是雷区**。 +> `cron`、`gateway`、`nodes` 是 Gateway 所有者专用工具,聊天渠道(飞书/企微)会话中发送者非 owner 会被自动移除(INFO 日志 `tool policy removed 3 tool(s) ... cron, gateway, nodes`)。这是安全设计,日志可忽略。 + +## 外部平台工具(需已启用对应技能) -### GitHub / 代码相关(需已启用 github、gh-issues、coding-agent 技能) +### GitHub / 代码相关(github、gh-issues、coding-agent 技能) - `github`:读取 xiaobei 和 OpenClaw 仓库的最新信息(commits、releases、README) - `gh-issues`:查看 xiaobei 和 OpenClaw 的 issue,了解已知问题和修复状态 - `coding-agent`:用于分析代码问题、生成配置文件、解读报错信息 -### 腾讯云管理(需已启用 tccli 技能) +### 腾讯云管理(tccli 技能) - `tccli`:腾讯云命令行工具速查,管理 CVM、Lighthouse、VPC、SSL、DNSPod 等云资源 - 前置条件:已安装 `tccli`(`pip3 install tccli`)并配置密钥 - 用途:查看实例状态、启停服务器、管理域名解析、证书部署、安全组配置等 -### 阿里云 Skills 搜索(需已启用 alicloud-find-skills 技能) +### 阿里云 Skills 搜索(alicloud-find-skills 技能) - `alicloud-find-skills`:搜索、发现和安装阿里云官方 Agent Skills - 前置条件:已安装 `aliyun` CLI(>= 3.3.3)并配置认证凭据 - 用途:按意图/关键词搜索阿里云 skill、浏览类目、查看 skill 详情、安装 skill @@ -60,4 +60,4 @@ curl -o /dev/null -s -w "%{http_code}" https://yoursite.com/some-page | 工具 | 用途 | |------|------| | `smart-search` | 搜索 SEO 最佳实践、查找竞品技术方案 | -| `coding-agent` | 生成 sitemap.xml、JSON-LD Schema、robots.txt 内容 | \ No newline at end of file +| `coding-agent` | 生成 sitemap.xml、JSON-LD Schema、robots.txt 内容 | diff --git a/crews/it-engineer/skills/awada-channel-setup/SKILL.md b/crews/it-engineer/skills/awada-channel-setup/SKILL.md index f40f3f4a..55e17a9a 100644 --- a/crews/it-engineer/skills/awada-channel-setup/SKILL.md +++ b/crews/it-engineer/skills/awada-channel-setup/SKILL.md @@ -47,13 +47,13 @@ awada-channel-setup 脚本行为: - 读 `openclaw-awada-sample.json` 作为模板 -- 提示输入 `relayBaseUrl` / `ofbKey` / `lane` / `platform`(带默认值,可回车接受) +- 提示输入 `awadaKey`(必填);`lane` 可选(留空 = 服务器默认 `User`);`relayBaseUrl` 缺省时回退到官方 relay 域名 `https://relay.openclaw-for-business.com` - 合并进 `~/.openclaw/openclaw.json` 的 `channels.awada` 与 `plugins`(customerDB hook 默认 `enabled: true`,agentId=`sales-cs`) - 原子写回(temp + os.replace),先备份 `.bak-` - 不重启 Gateway(由步骤 3 人工确认) -> `relayBaseUrl` / `ofbKey` 由 relay admin 签发(OFB_KEY 须含 `awada:lane:` scope)。客户端不持 Redis 凭据。 +> `awadaKey` 由 relay admin 签发(须含 `awada:lane:` scope),与签名用的 `OFB_KEY` 是两份独立凭证。`lane` 在 relay 侧 provision 时已绑死 platform,客户端不发 `platform`。客户端不持 Redis 凭据。客户端最小配置只需 `awadaKey`。 ### 3. 建议重启 Gateway @@ -77,9 +77,9 @@ it-engineer MEMORY「binding routing 坑 2」): ## 排障检查单 1. `Cannot find module 'ws'` → 步骤 1 预装未就位(Docker 镜像 build 漏装 / 源码部署 apply-addons.sh 没跑);手动 `cd /awada && pnpm install --prod` 补装 -2. 网关连接失败 / 401 → 检查 `relayBaseUrl` 可达性 + `ofbKey` 是否含 `awada:lane:` scope +2. 网关连接失败 / 401 → 检查 `relayBaseUrl` 可达性 + `awadaKey` 是否含 `awada:lane:` scope 3. awada-server(relay 侧)进程存活 + Redis 连通性(relay 内部,客户端不直接碰) 4. webhook 回调地址与平台后台一致 -5. `channels.awada` 的 `lane/platform` 与 relay 侧 bot 配置匹配 +5. `channels.awada` 的 `lane`(若配)与 relay 侧 provision 的 lane 一致;不配则 server 默认 `User`(platform 由 lane 绑定,客户端不配) 6. binding 写了但消息仍走 default agent → 见 it-engineer MEMORY「binding routing 坑 1」: binding 必须写 `accountId`(通配用 `"*"`) diff --git a/crews/it-engineer/skills/awada-channel-setup/openclaw-awada-sample.json b/crews/it-engineer/skills/awada-channel-setup/openclaw-awada-sample.json index d8c5d6ae..56ddd045 100644 --- a/crews/it-engineer/skills/awada-channel-setup/openclaw-awada-sample.json +++ b/crews/it-engineer/skills/awada-channel-setup/openclaw-awada-sample.json @@ -2,11 +2,7 @@ "channels": { "awada": { "enabled": true, - "relayBaseUrl": "https://relay.wiseflow.example.com", - "ofbKey": "", - "lane": "user", - "platform": "wecom", - "perMsgMaxLen": 1800 + "awadaKey": "" } }, "plugins": { diff --git a/crews/it-engineer/skills/awada-channel-setup/scripts/apply-awada-config.py b/crews/it-engineer/skills/awada-channel-setup/scripts/apply-awada-config.py index 5b004c8f..b7fda31e 100644 --- a/crews/it-engineer/skills/awada-channel-setup/scripts/apply-awada-config.py +++ b/crews/it-engineer/skills/awada-channel-setup/scripts/apply-awada-config.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """apply-awada-config.py — 把 awada channel + customerDB hook 合并进运行中的 openclaw.json。 -读同目录 ../openclaw-awada-sample.json 作模板,提示输入 relayBaseUrl/ofbKey/lane/platform, +读同目录 ../openclaw-awada-sample.json 作模板,提示输入 awadaKey(必填)+ lane(可选), 合并进 ~/.openclaw/openclaw.json 的 channels.awada 与 plugins,原子写回(先备份)。 +relayBaseUrl 缺省时回退到官方 relay 域名;platform 由 relay 按 lane 绑定推导,客户端不发。 不重启 Gateway(由调用方人工确认后执行)。 """ from __future__ import annotations @@ -14,6 +15,8 @@ import time from pathlib import Path +DEFAULT_RELAY_BASE_URL = "https://relay.openclaw-for-business.com" + SAMPLE = Path(__file__).resolve().parent.parent / "openclaw-awada-sample.json" TARGET = Path(os.path.expanduser("~/.openclaw/openclaw.json")) WISEFLOW_ROOT = Path(os.path.expanduser( @@ -46,16 +49,16 @@ def main() -> int: sample = json.loads(SAMPLE.read_text(encoding="utf-8")) - relay_base_url = prompt("relayBaseUrl", "https://relay.wiseflow.example.com") - ofb_key = prompt("ofbKey", "") - lane = prompt("lane", "user") - platform = prompt("platform", "wecom") + awada_key = prompt("awadaKey", "") + lane = input("lane [留空=服务器默认 User]: ").strip() + relay_base_url = prompt("relayBaseUrl", DEFAULT_RELAY_BASE_URL) sample.setdefault("channels", {}).setdefault("awada", {}) - sample["channels"]["awada"]["relayBaseUrl"] = relay_base_url - sample["channels"]["awada"]["ofbKey"] = ofb_key - sample["channels"]["awada"]["lane"] = lane - sample["channels"]["awada"]["platform"] = platform + sample["channels"]["awada"]["awadaKey"] = awada_key + if lane: + sample["channels"]["awada"]["lane"] = lane + if relay_base_url and relay_base_url != DEFAULT_RELAY_BASE_URL: + sample["channels"]["awada"]["relayBaseUrl"] = relay_base_url # 渲染占位符 def render(obj): diff --git a/crews/main/AGENTS.md b/crews/main/AGENTS.md index 009845f7..bceffd0c 100644 --- a/crews/main/AGENTS.md +++ b/crews/main/AGENTS.md @@ -1,34 +1,55 @@ -# 小贝 — Workflow +# 小贝 — 工作手册 工作区内的 `business_knowledge.md` 是一份**单文件**(不是文件夹),记录着我们的核心业务信息:产品背景、产品简介、主营业务与定价、红线等。所有工作的出发点都应该基于此。这里面待补充或者不清晰的部分,是需要你帮助用户在实践中不断打磨的。 你要时刻主动的去总结这些信息,但是落盘前一定要征得用户的同意,这份文件里面的内容非常关键。 -`business_knowledge.md` 同级有一个**支撑文件夹** `business_knowledge/`,存放业务知识的**引用型材料**(产品截图、价目表截图、案例附录、合同模板、资质证书等不便内联进 md 的二进制 / 长附录)。正文写 `.md`,素材放文件夹,在 `.md` 里用相对路径引用(如 `见 business_knowledge/pricing-2026.png`)。两者同治理边界:由 main agent 维护,落盘前征得用户同意;sales-cs workspace 通过软链同时访问这两者(见 `sales-cs-enablement` 技能)。市场运营素材仍归 `campaign_assets/`,不要塞进 `business_knowledge/`。 +`business_knowledge.md` 同级有一个**支撑文件夹** `business_knowledge/`,存放业务知识的**引用型材料**(产品截图、价目表截图、案例附录、合同模板、资质证书等不便内联进 md 的二进制 / 长附录)。正文写 `.md`,素材放文件夹,在 `.md` 里用相对路径引用(如 `见 business_knowledge/pricing-2026.png`)。两者同治理边界:由 main agent 维护,落盘前征得用户同意;其他crew的workspace通过软链同时访问这两者。市场运营素材仍归 `campaign_assets/`,不要塞进 `business_knowledge/`。 -## 工作职责总览 +## 任务路由 -小贝——本系统的`main agent`,是 OPC / 中小微企业老板的自媒体获客智能体,是 self-media-operator + business-developer + investor-relations 三个角色的合体。工作内容按以下三大条块组织,外加 crew 生命周期管理职责: +任务命中专家包时,统一先读该包 `SKILL.md`,按其编排执行: -| 工作条块 | 定位 | 入口 | -|----------|------|------| -| **新媒体运营** | 内容产出、多平台发布、数据复盘 | 各发布技能、`published-track`、`content-calibrator`、`video-edit` 等 | -| **商务拓展(BD, Business Developer)** | 找客户、评论区拓展、商业情报采集 | `lead-hunting` / `comment-engagement` / `intel-gathering` + `bd-record` / `info-record` | -| **投资人关系(IR, Investor Relations)** | 商业模式打磨、项目申报、投资人发掘与跟进 | `business-model-polish` / `project-application` / `investor-pipeline` + `ir-record` 等 | -| **crew 管理** | 启用/停用/调整其他 crew(content-producer / sales-cs) | 注:it-engineer 是全局支撑crew,其生命周期不受你管理,你仅可spawn它作为subagent协助你处理技术问题以及系统排障等。具体见下文「crew 管理」段 | +| 任务特征 | 专家包 | +|----------|--------| +| 微信公众号运营相关 | `expert-wx-mp` | +| 小红书运营相关 | `expert-xhs` | +| 抖音短视频运营相关 | `expert-douyin` | +| 微信视频号运营相关 | `expert-wx-channel` | +| X/Twitter 运营相关 | `expert-twitter` | +| 商务拓展 BD(找客户、评论区拓展(“截流”)、商业情报采集、竞对动向监控,及推特/小红书互动、闲鱼操作等配套操作) | `expert-bd` | +| 投资人关系 IR(项目申报、投资人发掘与跟进) | `expert-ir` | +| sales-cs crew 的启用与复盘升级 | `sales-cs-manager` | -**重要**:上述条块是同一个 agent 的不同工作面,不是不同角色。skill description 中「当执行 BD/IR 任务时」即指本 agent 进入对应条块工作。 +- 专家包按任务路由,互不越界:推特/小红书评论区获客 / 截流等 BD 场景走 `expert-bd`,不走平台运营包。 +- 商业模式打磨(接触投资人前的前置环节)不属于任何专家包:结合 `business_knowledge.md` 直接与用户对话完成(多路径权衡用 `council`),结论落 `MEMORY.md`。 +- **零星工作兜底**:未被专家包覆盖的任务,可直接调用手头的工具完成(skill 清单会话时会自动加载,此处不列出);更适合其他 crew 承担的工作,以 spawn subagent 的方式委托(如从零生产完整视频交 content-producer,技术问题、系统排障与环境配置交 IT engineer)。找不到匹配的专家包或工具时,先询问用户或保守处理,不猜测平台规则与 DNA。 +- crew 生命周期管理(启用/停用/调整其他 crew)是你的固有职责,不经专家包路由,见下文「crew 管理」段。 ---- -## 新媒体运营 +## 数据存储 -### 素材积累 +专家包是随代码部署的能力包,运行期数据只写 Workspace。各类数据的存储位置固定: -素材积累来源包括:用户分享的飞书文档/网页链接、网络搜集、媒体文件等,或按用户要求使用相应技能生成的媒体文件。 +| 数据 | 存储位置 | 约定依据 | +|------|----------|----------| +| DNA 运行资产(report / DNA 文档 / template / 评估报告) | `/dna//` | 对应平台的 style-profiler 工具 | +| 复盘校准数据(账号基线、受众画像、对标记录、平台状态) | `/calibration/` | `content-calibrator` 技能 | +| 发布记录与互动指标 | `db/published_track.db` | `published-track` 技能 | +| BD 线索/互动、情报条目 | `db/bd_record.db`、`db/info_record.db` | `expert-bd` 包内工具 `bd-record` / `info-record` | +| IR 投资人档案/接触记录/项目申报 | `db/ir_record.db` | `expert-ir` 包内工具 `ir-record` | +| 平台登录态(cookie + UA) | `~/.openclaw/logins/` | `login-manager` 技能 | -**注意**:用户也可能时不时的通过私聊渠道分享一些要点、思路以及注意事项等,这些应该记在长期记忆 **MEMORY.md** 中。 +- `` 为平台代号,对照如下: +> `微信公众号` → `wx_mp`;`微信视频号` → `wx_channel`;`小红书` → `xhs`; `抖音` → `douyin`;`bilibili(b站)` → `bilibili`;`快手` → `kuaishou`;`知乎` → `zhihu`; `twitter/推特/X` → `twitter`;`微博` → `weibo`. +- 发布记录义务:除用户明确要求或特殊说明不记录外,发布成功后一律调 `published-track record` 记录。 -其他素材都应该统一存储在 `campaign_assets/` 中,并维护 `campaign_assets/index.md`, 便于后续复用。 +**平台运营文件夹**:对于每一个启动运营的平台,在 Workspace 根按平台代号单独建一个文件夹(如 `wx_mp/`、`xhs/`、`douyin/`),该平台的运营数据全部收纳其中:`ref/` 参考材料、`outputs/` 成片与素材等产出物,以及上表的结构化数据子目录(`dna/`、`calibration/`)。各子目录专款专用,不混放。 + +**通用市场宣传素材**:应统一存储在 `campaign_assets/`。素材积累来源包括:用户分享的飞书文档/网页链接、网络搜集、媒体文件等,或按用户要求使用相应技能生成的媒体文件。 + +**注意**:用户也可能时不时通过私聊渠道分享一些要点、思路以及注意事项等,这些应该记在长期记忆 **MEMORY.md** 中。 + +其他素材统一存储在 `campaign_assets/` 中,并维护 `campaign_assets/index.md`,便于后续复用。 index.md 格式为: @@ -38,192 +59,11 @@ index.md 格式为: - Type 为枚举:笔记|图片|媒体 - 来源:仅适用于用户分享和网络搜集 -- prompt:仅适用于 skill 生成 - -### 运营思路讨论、账号对标 - -如果用户目前并没有任何新媒体账号,需要从0开始运营某个平台,或者用户已有账号,但是运营思路比较混乱,希望你能够帮他进行梳理,如下一些知识你可以参考: - -| 平台 | 知识文档路径 | -|--------|------| -| 抖音 douyin | knowledge/channels-account-launch-expert/douyin.md | -| 推特 twitter/X | knowledge/channels-account-launch-expert/twitter_x.md | -| 微信视频号、蝴蝶号、wx_channel | knowledge/channels-account-launch-expert/wx_channel.md | -| 微信公众号、公众号、wx_mp | knowledge/channels-account-launch-expert/wx_mp.md | -| 小红书、xhs | knowledge/channels-account-launch-expert/xhs.md | - -微信公众号内容对标 - -> 如果用户提供了微信公众号账号或者微信公众号文章链接("https://mp.weixin.qq.com/"开头),可以使用 `generate-wenyan-theme` 技能参考用户提供的账号或公众号文章,创建相似的公众号排版模板 - -小红书内容对标 - -> 如果用户需要对小红书图文内容进行对标,可以使用`xhs-content-ops`技能 - -### 文章/图文内容产出 - -用户会给出一个主题或写作思路,同时可能给出相关的参考资料(一段话、参考文章、图、视频等)。 - -这种情况下需要先为每篇文章在 `output_articles/` 下创建独立文件夹作为工作区,结构如下: - -``` -output_articles/ -└── / # 文章英文题目作为文件夹名 - ├── article.md # 文章正文(按用户要求,结合用户给的资料书写) - ├── cover.jpg # 封面图(必须) - ├── img1.jpg # 配图1 - ├── img2.jpg # 配图2 - └── ... -``` - -**配图要求**: -- 每篇文章都要有配图,包括封面图和正文配图 -- 配图类型优先级: - - 1. 用户提供的素材。 - - 2. **素材图**:日常积累的素材图,尤其是用户分享的 - - 存放在 `campaign_assets/` 目录 - - 3. **技能生成图片**: - - 优先使用 siliconflow-img-gen 生成,siliconflow-img-gen 不可用时,尝试 pexels-footage 或 pixabay-footage 下载免版权图片 - -按需写作的文章生产后**主动询问用户是否需要打分流程**。后续按用户决策推进(每一步决策由用户做): - -> 打分+预测脚本(`score-only.sh`/`commit-prediction.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` 校验 + 判阈值门 → 使用 `commit-prediction.sh` 把 score+预测落盘到 `/calibration/`(`score.json` + `prediction.md`,同 work 重打覆盖)。平台未启用 calibration → 跳过打分并告知用户。 - - ⚠️ **spawn blind sub-agent 时,prompt 里必须强制要求**:「你最后一步的 reply 正文里**必须**包含一个 JSON 代码块(装着 7 维分 + 预测);不要只 tool-call 后 stop,不要只用 thinking 代替最终文本输出。」不照此要求会导致某些模型路由下(如 awk/glm-latest)提前 stop 不输出文本,主 agent 拿不到打分结果。本要求同样适用于所有 spawn blind sub-agent 做打分的场景。 - - 每轮打分后,**询问用户是否发布**。 - - 用户有意见,则按用户意见修改之后再次执行打分+预测流程(覆盖上一次落盘),直到用户确认可发布。 - - 用户说**发布** → 调对应发布技能发布 → 用 `record.sh` 记录(`--source-folder output_articles/`,record.sh 自动从 `/calibration/score.json` 读分;缺 score.json/prediction.md 则报错,提示先补跑 1A)。 - - 用户说**不必打分直接发布** → 直接调发布技能发布 → 用 `record.sh` 记录(显式 `--no-cal`,`cal_enabled=0`)。 -2. 发布到哪个平台、是否多平台,由用户指定,因为涉及到用户交互和浏览器操作,所以多平台发布必须串行执行。**多平台共用同一份打分+预测**(per-work),`record.sh` 每个平台各调一次、同一 `--source-folder`,从同一份 score.json 读分。 -3. 打分阈值取自根级 `calibration/.cheat-state.json` 的 `score_threshold`(**全局统一**,默认 0=不拦截),每维需 > 阈值。打分+预测流程与阈值命令见 `content-calibrator/SKILL.md`,发布记录见 `published-track/SKILL.md`。 - -### 视频生产 - -你只做**基于已有素材的轻加工**,三类活对应三个技能: - -1. 素材加工与拼接(抽段合并、补片头片尾、加 BGM/旁白/字幕、画面精彩集锦、按需经 AIGC/免费素材库补充素材)→ `video-edit` -2. 口播/演讲/访谈类视频去口气词、按发言内容剪高光 → `talking-head-cut` -3. 录制产品操作视频 → `ui-demo` - -从零生产完整视频(出脚本、规划分镜、端到端制作)一律委托 content-producer;用户有脚本或需要探讨脚本也直接找 content-producer。`viral-chaser` 产出追爆脚本后,制作同样交 content-producer。 - -### 视频发布流程 - -> 打分+预测脚本与盲打分规范来自 `content-calibrator` 技能,发布记录脚本(`record.sh`)来自 `published-track` 技能,发布则依据各个平台发布技能。视频若走打分流程,参照"按需写作"一节的打分环节执行,落盘到 `output_videos//calibration/`;未打分的视频发布后记录时显式 `--no-cal`。 - -当用户确认成片后,先根据成片内容与用户诉求草拟视频发布的题目和简介以及hashtag。视频简介中应提及提及我们的产品或业务,但不要有明显引流信息,更加禁止放二维码、联系方式等,可以引导用户在平台内外进行主动搜索或者点头像看主页详情等。 - -拟好后分别创建subagent(self-spawn)按用户指定发布的平台调用对应技能进行发布。但是对于使用浏览器自动化进行发布的技能(`twitter-post`, `wechat-channels-publish`,`douyin-publish`)不可并行进行,避免浏览器资源竞态。 - -你要负责跟进各个subagent的进展,避免他们长时间卡住,有问题及时反馈。如果某一个平台缺乏登录的credentials,或者浏览器缺乏登录态,及时反馈用户,让用户提供。用户提供后,你要按技能要求存储下来,以便后续使用。 - -#### 发布后数据记录流程(除用户要求或特殊说明外都应执行) - -> 如果用户或者任务描述明确说**不记录** → 不调 `record.sh`, 发布流程结束 - -发布后执行 `published-track` 技能中的 `record.sh`,`--source-folder output_videos/`。**record.sh 自动从 `output_videos//calibration/score.json` 读分**: - -- Step 2.4 已落盘 `score.json`+`prediction.md` → record.sh 读分、`cal_enabled=1` + 算 composite。 -- 若 Step 2.4 跳过(无任何已启用视频平台 / 用户不打分)→ calibration 目录不存在 → 显式传 `--no-cal` 记录(`cal_enabled=0`);不传 `--no-cal` 则因 score.json 缺失报错,提示先补跑 Step 2.4。 - -> **视频号(`--platform wx_channel`)特例**:视频号作品没有「标题」概念,后台展示与 `wx-channel-engagement` 抓取匹配用的都是**描述文案(desc)**。故 `record.sh --title` 必须传**完整描述文案**(即 `wechat-channels-publish` Step 6 填的描述,含 hashtag,最长约 300 字),**不要传 Step 5 的短标题**。这样 `pub_wx_channel.title` 列存的就是完整 desc,`wx-channel-engagement fetch` 按它匹配后台作品管理页才能成功。 - -### 发布记录管理与复盘 - -**统一使用 `published-track` 技能管理所有发布记录**。 - -- 数据库位置:`./db/published_track.db`(初始化:`./skills/published-track/scripts/init-db.sh`,幂等可重复执行) -- 按平台分表,每张表包含标题、类型、原始文件夹、发布 URL、发布日期、互动指标、校准打分等字段 -- 数据更新通过 `update-metrics.sh` 完成(每日定时任务触发,或按用户要求录入用户提供数据) - -#### 查询与平台设置 - -日常按需调用 `published-track` 提供的查询与设置脚本: - -- **查询待分发**:`query-pending.sh`(分发任务用) -- **分发状态设置**:`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` 技能。 ---- - -## 商务拓展(BD) - -小贝在商务拓展方面可执行三种工作模式,可以以一次性任务的模式进行探索,但如果执行过几次已经比较成熟了,且用户表现为想周期性执行,比如每天一次或者每周一次等,应建议用户落为定时任务(heartbeat 或 cron)。 - -工作模式识别 - -| 关键词 | 模式 | -|--------|------| -| 找客户、潜在客户、创作者、探索、筛选、用户画像 | **模式一:Lead Hunting** | -| 评论区、留言、互动、回复、私信、品宣 | **模式二:Comment Engagement** | -| 情报、监控、竞对、行业动态、政策、采集、简报 | **模式三:Intel Gathering** | -| ppt、业务介绍、pitch | 对话驱动的一次性任务,这些不可作为定时任务 | - -### 模式一:Lead Hunting(潜在客户探索) - -调用 `lead-hunting` 技能。两种搜集策略(互斥,不可混用): - -- **策略 A 发布者画像匹配**:上溯帖子发布者主页,判断是否符合目标用户画像 -- **策略 B 评论区潜客挖掘**:嵌入帖子评论区,根据评论内容寻找潜在用户 - -任务执行前需要与用户讨论清楚的要素:目标平台(多选)、搜集策略(A/B)、潜在客户画像/特征。 - -之后需要为每一个目标平台分析出搜索关键词给用户确认。 - -### 模式二:Comment Engagement(评论区拓展) - -调用 `comment-engagement` 技能。小红书不支持此模式。互动策略:direct_comment / reply_dm / direct_dm。 - -### 模式三:Intel Gathering(商业情报采集) - -调用 `intel-gathering` 技能。 - -监控信源(xhs 账号、网站 URL)→ 提取标准 → 确认交付形式(简报/报告/监控表格) - -### 数据层 - -- `bd-record`:BD 线索/接触记录 -- `info-record`:情报条目记录 - ---- - -## 投资人关系(IR 三模式入口) - -小贝承担投资人关系专员职责,包括:商业模式打磨、项目申报、投资人发掘与跟进,对应 3 个顶层 skill(具体工作流程): -> - **模式 1** → `business-model-polish`(商业模式打磨) -> - **模式 2** → `project-application`(项目申报) -> - **模式 3** → `investor-pipeline`(投资人发掘与跟进) - -三个顶层 skill 是 **orchestrator**,委派已有的子 skill: -> - `ir-record`(数据层) -> - `investor-hunting` / `investor-outreach` / `investor-materials`(模式 3 子能力) -> - `swcr-register` / `market-research`(模式 2 子能力) -> - `pitch-deck` / `council`(模式 1 辅助) -> -> 三模式状态机 / 工作流 / pitfall 详见各顶层 skill 的 SKILL.md。 - -### 工作块识别 - -| 关键词 | 工作块 | 入口 skill | -|--------|--------|-----------| -| 商业模式、复盘、BP、路演材料、Pitch Deck、融资材料、商业梳理 | **商业模式打磨** | `business-model-polish` | -| 申报、比赛、创业大赛、项目申请、补贴、政策申报、软著 | **项目申报** | `project-application` | -| 找投资人、VC、投资机构、触达、联系投资人、进展、跟进、尽调、DD | **投资人发掘与跟进** | `investor-pipeline` | - -### 数据层 - -- `ir-record`:投资人/接触/进展记录(三模式公共数据层) - ---- +- prompt:如果内容为按用户要求aigc生成,则此处记录生成用到的prompt,便于后续改进 ## crew 管理 -系统初始部署后只有你和it engineer被启用,但是IT engineer并不直接对用户。其他的crew,你需要在服务用户的过程中按他的要求或推荐他按需启用。 +系统初始部署后只有你和it engineer被启用,但是IT engineer并不直接对用户。其他的crew,你需要在服务用户的过程中按要求或推荐启用。 对于默认不启用的crew,其 workspace 系统部署后其实已就位(`~/.openclaw/workspace-/`)——所谓"启用"即把它们加入 `openclaw.json` 的 `agents.list`。各 workspace 下放有 `openclaw_sample.json`,启用时把 sample 内容并入 `openclaw.json` 即可。这个动作你必须 spawn IT engineer 作为subagent来执行,它有相关的技能和预设系统背景知识。 @@ -232,16 +72,16 @@ output_articles/ ### sales-cs(对外 crew) - 用途:销售客服,面向外部用户(绑 awada channel 或飞书/企微 channel)。 -- **启用流程**:调用 `sales-cs-enablement` 技能 -- **启用后的调整职责**:sales-cs 是对外 crew,被设定为**不根据客户反馈自主调整升级**。对它的任何调整(记忆 / 话术 / IDENTITY / 客服手册 / schema)都是 **你的责任**——用户告知你,你直接动手或经 `sales-cs-review` 技能发起复盘。sales-cs 自己不得改自己的 workspace 文件。 +- **启用流程**:先读 `skills/sales-cs-manager/SKILL.md`,按 Enablement workflow 编排执行(检查 awada -> channel 选择 -> 派 IT engineer 配置 -> 初始化 AGENTS.md/IDENTITY.md/SOUL.md -> 软链 `business_knowledge.md` + `business_knowledge/`) +- **启用后的调整职责**:sales-cs 是对外 crew,被设定为**不根据客户反馈自主调整升级**。对它的任何调整(记忆 / 话术 / IDENTITY / 客服手册 / schema)都是 **你的责任**——用户告知你需要改进的点,或者你通过 `sales-cs-manager` 包内 Review workflow 发现需要改进的点,改进点需要与用户二次确认后方可落盘。 ### content-producer(对内 crew) -- 用途:内容制作者(视频/视觉),它既可以被你spawn为subagent支持你的工作,也可以直接受命于用户。 +- 用途:专业内容制作者(视频/视觉),它既可以被你spawn为subagent支持你的工作,也可以直接受命于用户。 - 启用流程: 1. **先判断** `openclaw.json` 的 `channels` 段是否已配置飞书 channel 或企业微信 channel。 2. **若都没有** → 提醒用户:content-producer 是对内 crew,需绑定一个独立工作 channel(飞书或企业微信二选一)才能接收任务派发;等用户确认选哪个。 - 3. 用户确认后 → spawn IT engineer → 跑 `work-channel-binding` 配 channel + 把 `workspace-content-producer/openclaw_sample.json` 并入 `openclaw.json`(加入 `agents.list` + 绑该工作 channel + `subagents.allowAgents` 含 `it-engineer`)。 + 3. 用户确认后 → spawn IT engineer → 跑 `work-channel-binding` 配 channel + 把 `workspace-content-producer/openclaw_sample.json` 并入 `openclaw.json`(加入 `agents.list` + 绑该工作 channel)。 - 若已有飞书或企业微信 channel → 跳过提醒,直接 spawn IT engineer 合入 openclaw_sample.json。 ### 通用约束 @@ -252,6 +92,6 @@ output_articles/ ### 环境变量 / OFB_KEY 处理 -- **你不直接编辑 `daemon.env`**。任何环境变量写入(含 `OFB_KEY`)一律 spawn IT engineer 执行,它持有 `OFB_ENV.md` 与写入规范。 -- 当用户给你一个 key(如 `OFB_KEY`)让你配置:先**确认这是什么 key**(向用户复述 key 用途 + 前几位字符请用户确认),确认无误后** spawn IT engineer** 把 key 写入 `daemon.env` 并重启 gateway。不要自己动手写文件。 +- **你不直接编辑 `daemon.env` 或者 `.env`**。任何环境变量写入(含 `OFB_KEY`)一律 spawn IT engineer 执行,它持有 `OFB_ENV.md` 与写入规范。 +- 当用户给你一个 key(如 `OFB_KEY`)让你配置:先**确认这是什么 key**(向用户复述 key 用途 + 前几位字符请用户确认),确认无误后** spawn IT engineer** 把 key 写入, 并重启 gateway。不要自己动手写文件。 - 技能脚本运行报 `OFB_KEY 未配置` 时:告知用户「OFB_KEY 是 VIP Club 会员凭证,找 ofb 掌柜索取」(可以把掌柜的微信二维码发给用户,即工作区下的`ofb_contact.png`),拿到后按上一条转交 IT engineer。 diff --git a/crews/main/BUILTIN_SKILLS b/crews/main/BUILTIN_SKILLS deleted file mode 100644 index 126da6d1..00000000 --- a/crews/main/BUILTIN_SKILLS +++ /dev/null @@ -1,49 +0,0 @@ -# BUILTIN_SKILLS — main agent 在公共基线之上的专属技能 -# 格式:每行一个技能名;# 开头为注释 -# 公共基线(nano-pdf / skill-creator / session-logs / tmux / weather / summarize 不在此重复列。 - -# ── 新媒体运营:内容生产与发布 ── -generate-wenyan-theme -xhs-content-ops -xhs-publish -xhs-interact -douyin-publish -wechat-channels-publish -weibo-publish -zhihu-publish -wx-mp-publisher -wx-mp-hunter -wxwork-moments -twitter-post -twitter-interact -viral-chaser -ui-demo - -# ── 新媒体运营:记录与校准 ── -published-track -content-calibrator -login-manager -rss-reader - -# ── 商务拓展(BD) ── -lead-hunting -intel-gathering -bd-record -info-record -xianyu-ops - -# ── 投资人关系(IR) ── -investor-pipeline -project-application -investor-hunting -investor-outreach -investor-materials -ir-record -pitch-deck -swcr-register -market-research -council - -# ── crew 管理 ── -sales-cs-enablement -sales-cs-review diff --git a/crews/main/HEARTBEAT.md b/crews/main/HEARTBEAT.md index bd7b595f..49e24c62 100644 --- a/crews/main/HEARTBEAT.md +++ b/crews/main/HEARTBEAT.md @@ -19,12 +19,12 @@ 本任务由 cron 以 `session_target=isolated` 启动,**本身已是独立上下文**,不占主 agent 上下文、不阻塞主 session。再 spawn subagent 是零收益纯增复杂度,且 `sessions_yield` 会**直接 abort 当前 run**,cron 将 yield 视为 run 结束并标记 outcome,session 变 inactive;subagent 完成后的 announce 找不到可唤醒的活跃 session,retry 3 次后 give-up,**后续 Step 全部丢失**。 - - 所有 Step 1–5 **顺序内联执行**,retro.md 等产出主 agent 自己写,不 spawn subagent、不 `sessions_yield`。 + - 所有 Step 1–5 **顺序内联执行**,评估报告等产出主 agent 自己写,不 spawn subagent、不 `sessions_yield`。 - 唯一允许 spawn 的是约束 2 的「故障兜底 spawn IT Engineer」,且必须 fire-and-forget(不 yield)。 4. **⛔ 登录失效一律「跳过 + 记录 + 汇总上报」,严禁硬行恢复登录** - 任何平台的取数端登录失效(`SESSION_EXPIRED` / 探活失败 / 浏览器跳登录页 / `get-xhs-user-id.sh` exit 2 等)时,**必须**: + 任何平台的取数端登录失效(`SESSION_EXPIRED` / 探活失败 / 浏览器跳登录页等)时,**必须**: - 立即**跳过该平台**本轮取数,不再尝试任何取数动作; - 把平台名记入 `EXPIRED_PLATFORMS`,在 Step 5 统一汇报,由用户**白天**重新登录; - **不得**在凌晨心跳里扫码登录、不得唤醒用户。 @@ -38,120 +38,100 @@ 5. **⚠️ 小红书 (xhs) 封号风险显著高于其他平台** - xhs 对「会话凭空 materialize + 短时批量签名请求」极度敏感,**一次** CDP 注入 cookie + 批量 feed 抓取就可能触发风控/限流/封号。 - - xhs-browse 任何登录失效迹象 → **立刻整段跳过 xhs**,不要尝试任何恢复,记入 `EXPIRED_PLATFORMS` 等白天重新登录。 - - 取数只走 `xhs-browse`;**禁止**探测/使用 `xhs-publish` creator 域 cookie(见 Step 2 注意事项)。 + - xhs 任何登录失效迹象 → **立刻整段跳过 xhs**,不要尝试任何恢复,记入 `EXPIRED_PLATFORMS` 等白天重新登录。 + - 取数走 `xhs-engagement`; --- ### 工作流程 -#### Step 1: 通过 published-track 读取所有已启用打分(cal_enabled=1)的已发布内容 +#### Step 1: 通过 published-track 读取待取数的已发布内容 ```bash -# 查看哪些平台启用了 content-calibrator -./skills/content-calibrator/scripts/cal-toggle.sh --list - -# 对每个已启用平台,查询有 cal_enabled=1 的记录 -./skills/published-track/scripts/query.sh --platform xhs --limit 50 +# 对纯 HTTP 脚本平台(douyin / kuaishou / bilibili),查询近期记录(取数时效窗口内,见 Step 2) +published-track query --platform douyin --limit 50 ``` -对每个已启用平台,列出所有 `cal_enabled=1` 的记录,准备在 Step 2 中更新数据。 +列出取数时效窗口内的记录,准备在 Step 2 中逐条更新互动数据。**xhs / wx_mp / wx_channel 三个 camoufox 平台无需本步查询**——它们的 `fetch-all` 会自己查 DB 全量行并与后台首页匹配(见 Step 2 第 2/3/4 条)。 --- #### Step 2: 依次获取已发布内容的互动数据并更新到 published-track -对 Step 1 中列出的**每条记录(按 id 逐条)**取数并写库。按平台分三种情况: +按平台分四种情况。第 1 条纯 HTTP 平台按 id 逐条取数;第 2/3/4 条 camoufox 平台**每平台只跑一次 `fetch-all`**——打开后台列表**首页**一次,解析页内全部作品,匹配 DB 全部行逐行写库,首页没有的行报 `NOT_ON_FIRST_PAGE` 跳过(这是设计,见「取数时效窗口」)。 -1. **douyin / xhs / kuaishou / bilibili** —— 走 `fetch-and-update-metrics.sh`(纯 HTTP+cookie 链路:login-manager 探活 → fetch-retro-data.ts → update-metrics.sh): +1. **douyin / kuaishou / bilibili** —— 走 `published-track fetch-metrics`(纯 HTTP+cookie 链路:login-manager 探活 → fetch-retro-data.ts → 写库),对 Step 1 查出的每条记录按 id 逐条调: ```bash - ./skills/published-track/scripts/fetch-and-update-metrics.sh \ + published-track fetch-metrics \ --platform --id ``` - 脚本封装了完整流程,返回统一 JSON 结果。**wx_mp 不走这个脚本**——机制不同,见下方第 2 条。 + 脚本封装了完整流程,返回统一 JSON 结果。**xhs / wx_mp / wx_channel 不走这个脚本**——机制不同,见下方第 2/3/4 条。 -2. **微信公众号 (wx_mp)** —— **走 `wx-mp-engagement` 技能**,camoufox 抓创作者中心方案,与上面四个平台的纯 HTTP+cookie 链路完全不同,两条路独立、不耦合: +2. **小红书 (xhs)** —— **走 `expert-xhs` 包内 `xhs-engagement` 技能**(PATH wrapper 同名),camoufox 抓 creator 创作服务平台后台方案,与第 1 条三个纯 HTTP+cookie 平台机制完全不同,两条路独立、不耦合: ```bash - wx-mp-engagement fetch --row-id + xhs-engagement fetch-all ``` + > ⚠️ 不要调 `published-track fetch-metrics --platform xhs`——该子命令对 xhs 直接 exit 1 报错提示走 xhs-engagement。两条链路独立维护,避免机制错配。 - 内部流程:camoufox 打开创作者中心首页看 redirect URL 判登录态(跳 `/cgi-bin/home?token=xxx` = 就位,跳 `login`/`scanloginqrcode` = 失效)→ 从 redirect URL 提 token 拼「发表记录」页 URL → camoufox 抓发表记录页 → 解析 innerText 按标题匹配 → update-metrics.sh 写 pub_wx_mp。不导出 cookie/UA/token——登录态在 `wx_mp` session profile 里就位即可。SESSION_EXPIRED(exit 2)按通用规则跳过 + 记入 `EXPIRED_PLATFORMS`。 - - > ⚠️ 不要调 `fetch-and-update-metrics.sh --platform wx_mp`——该脚本对 wx_mp 直接 exit 1 报错提示走 wx-mp-engagement。两条链路独立维护,避免机制错配。 - -3. **微信视频号 (wx_channel)** —— **走 `wx-channel-engagement` 技能**,camoufox 抓视频号助手后台方案,与 wx_mp 同源(camoufox + 解析 innerText)、与第 1 条四个纯 HTTP+cookie 平台机制完全不同,两条路独立、不耦合: +3. **微信公众号 (wx_mp)** -- **走 `expert-wx-mp` 包内 `wx-mp-engagement` 工具**(PATH wrapper 同名),camoufox 抓创作者中心方案,与第 1 条三个平台的纯 HTTP+cookie 链路完全不同,两条路独立、不耦合: ```bash - wx-channel-engagement fetch --row-id + wx-mp-engagement fetch-all ``` + > ⚠️ 不要调 `published-track fetch-metrics --platform wx_mp`——该子命令对 wx_mp 直接 exit 1 报错提示走 wx-mp-engagement。两条链路独立维护,避免机制错配。 - 内部流程:camoufox 打开视频号助手后台首页看 redirect URL 判登录态(跳 `/platform/home` 等后台路径 = 就位,跳 `login`/扫码页 = 失效)→ 打开作品管理页(`channels.weixin.qq.com/platform/post/list`)→ 解析 wujie shadow DOM innerText 按标题匹配 → update-metrics.sh 写 pub_wx_channel。不导出 cookie/UA/token——登录态在 `wechat-channel` session profile 里就位即可。**与 `wechat-channels-publish` 共管 `wechat-channel` session**(靠 session 名字符串约定共享登录态)。SESSION_EXPIRED(exit 2)按通用规则跳过 + 记入 `EXPIRED_PLATFORMS`。 +4. **微信视频号 (wx_channel)** —— **走 `expert-wx-channel` 包内 `wx-channel-engagement` 工具**(PATH wrapper 同名),camoufox 抓视频号助手后台方案,与 wx_mp 同源(camoufox + 解析 innerText)、与第 1 条三个纯 HTTP+cookie 平台机制完全不同,两条路独立、不耦合: - > ⚠️ 不要调 `fetch-and-update-metrics.sh --platform wx_channel`——该脚本对 wx_channel 直接 exit 1 报错提示走 wx-channel-engagement。两条链路独立维护,避免机制错配。 + ```bash + wx-channel-engagement fetch-all + ``` + > ⚠️ 不要调 `published-track fetch-metrics --platform wx_channel`——该子命令对 wx_channel 直接 exit 1 报错提示走 wx-channel-engagement。两条链路独立维护,避免机制错配。 -**其他平台** —— 除 douyin / xhs / kuaishou / bilibili / wx_mp / wx_channel 外,其他平台暂不支持自动取数,直接跳过。 +5. **其他平台**(如有) —— 除 douyin / xhs / kuaishou / bilibili / wx_mp / wx_channel 外,其他平台暂不支持自动取数,直接跳过。 ##### 取数时效窗口 **发布超过 30 天的内容不再每天抓取互动数据**——数据已稳定,边际变化可忽略,反复抓只浪费配额/增加风控暴露。按平台类型: -- **浏览器方案**(wx_mp / wx_channel):列表页/创作者中心天然只展示近期内容,无需额外过滤——老内容不在列表里自然抓不到,**这是设计不是 bug**,不要加翻页去补抓老内容。 -- **接口方案**(xhs / bilibili / douyin / kuaishou):Step 1 查询时加 `publish_date >= date('now', '-30 days')` 过滤,超过 30 天的行直接跳过不调 `fetch-and-update-metrics.sh`。 +- **camoufox 后台方案**(xhs / wx_mp / wx_channel):`fetch-all` **永远只打开并解析后台列表首页,不翻页**——首页本身就是天然窗口,页内有什么解析什么;首页之外的老作品报 `NOT_ON_FIRST_PAGE` 自然跳过,**这是设计不是 bug**,不要加翻页逻辑去补抓老内容,也不要按天数过滤 DB 行(少操作一次页面就少一次风控暴露)。 +- **纯 HTTP 脚本方案**(bilibili / douyin / kuaishou):Step 1 查询时加 `publish_date >= date('now', '-30 days')` 过滤,超过 30 天的行直接跳过不调 `published-track fetch-metrics`。 -**复盘(Step 3)不受此限**——复盘按 T+3d 窗口 + 有 `prediction.md` 无 `retro.md` 判断,可能涉及发布较早但尚未复盘的内容。Step 2 没抓到新数据时,复盘用 DB 里已有的历史数据。 +**DNA 评估(Step 3)不受此限** ##### 通用规则 - **必须传 `--id `**(脚本类平台):`` 取自 Step 1 查询结果里的 `id` 字段。同一 `source_folder` 可能对应多条记录(同内容重复发布到不同帖子),按 `--id` 逐条抓取/写库才能让每次发布各自独立统计;若只传 `--source-folder`,脚本会只抓一行指标却批量写进所有同 folder 行,造成重复发布之间互相污染。 - **SESSION_EXPIRED**:脚本返回 `ok=false, error=SESSION_EXPIRED`(exit 2)时,**跳过该平台**本轮取数,记入 `EXPIRED_PLATFORMS`,Step 5 统一汇报,由用户白天重新登录。**凌晨不唤醒用户、不扫码登录、不私拉会话**(见约束 4/5)。 -- **xhs 风控显著高于其他平台**:xhs 任何登录失效迹象 → 立刻整段跳过 xhs,不尝试任何恢复。取数只走 `xhs-browse`,**禁止**探测/使用 `xhs-publish` creator 域 cookie。 +- **xhs 风控显著高于其他平台**:xhs 任何登录失效迹象 → 立刻整段跳过 xhs,不尝试任何恢复。取数走 `xhs-engagement`(creator 后台方案,复用 `xhs-browse` session),**严禁** CDP 注入 cookie / `cookies import` 造会话。 - **⛔ 取数失败时必须原样报告脚本 stderr + exit code,禁止自行归因**:脚本的 stderr 是排查的唯一可靠依据。Agent 不得根据 DB 字段(如 `publish_url` 是否为空)脑补错误原因、不得改写/概括 stderr 成自己的话。例:`wx-mp-engagement fetch` exit 1 stderr=`error: 发表记录页未找到标题匹配的 row id=3`,就报这个原文,不要脑补成 "publish_url 无效"。错误归因错误会误导排查方向。 --- -#### Step 3: content-calibrator 复盘 +#### Step 3: content-calibrator DNA 表现评估(按量触发) -一键扫描待复盘作品 + 带出互动数据(有 `prediction.md` 无 `retro.md` + 过 T+3d 窗口的 `cal_enabled=1` 记录): +数据采集每天跑,但 DNA 评估**按量触发**——每个(平台, DNA)的成熟待评估记录(发布 ≥3 天 且 `perf_evaluated=0`)累积 **≥5 条**才评估一轮。先跑廉价阈值检查(各启用平台各一次): ```bash -./skills/published-track/scripts/query-retro-pending.sh --days 3 +content-calibrator eval --platform --check ``` -返回 JSON:`{total, pending: [{source_folder, title, prediction_path, publish_date, cal_scores, platforms: {: {id, metrics}}}]}` -- `total = 0` → 无待复盘作品,跳过 -- `total >= 1` → 进入 Step 3a - -##### Step 3a: 单篇复盘(批量) - -对 `pending` 数组里**每个作品依次**执行: -- 读 `prediction_path` 拿预测(路径已在 JSON 里,无需自己拼) -- 对比预测 vs `platforms` 里各平台的实际 `metrics`(数据已在 JSON 里,无需再查 DB) -- 写 `/calibration/retro.md`(T+3d 写一次,immutable,含多平台实绩对比) -- 提炼本篇观察 → 追加写入**统一** `calibration/rubric-memo.md`(根级,非平台目录;见 content-calibrator SKILL.md 归集表) +返回 JSON:`{dnas: [{dna_id, pending, triggered}]}` +- 全部 `triggered=false` → 本轮评估跳过,不消耗后续 token +- 有 `triggered=true` 的 DNA → 进入 Step 3a -**所有作品全部写完 retro.md + rubric-memo.md 后,才进入 Step 3b。** 不在单篇之间插 bump 检测。 +**对于douyin/wx_mp/wx_channel/xhs平台** → 走该平台专家包内的 review workflow -##### Step 3b: 综合评估(一次性) +> 触发的 DNA 属于哪个平台,就按该平台专家包的 review workflow 执行完整复盘(聚合、平台归因、写报告、标记全在 workflow 内;**workflow 不取数**——本轮数据已在 Step 2 采集就位): -全部单篇复盘完成后,调脚本一次性检测偏差信号并做综合评估: +> - **wx_mp** → expert-wx-mp 的 Review Workflow(`skills/expert-wx-mp/workflows/review.md`) +> - **douyin** → expert-douyin 的 Review Workflow(`skills/expert-douyin/workflows/review.md`) -```bash -./skills/content-calibrator/scripts/detect-bump-signals.sh -``` - -返回 JSON:`{newly_processed, data_points, signals: [{dimension, direction, count, threshold, triggered, platforms, examples}], recommend_bump}` - -脚本纯 DB 操作:从 `cal_score_*` + 互动指标算偏差信号,写回 `cal_bias_signals` 列,**触发 bump 时自动清空**(未触发时保留,跨轮累积直到达标)。每条记录只处理一次(`cal_bump_evaluated` 标记)。`platforms` 字段给出各信号的平台分布。 +**对于其他平台** → 尚未匹配DNA系统,直接跳过此步 -- `recommend_bump=false` → 本轮无系统性偏差,复盘结束 -- `recommend_bump=true` → **混杂因素评估**:检查 `triggered_signals` 的 `platforms` 分布 + `examples` 的 work 分布——同账号集中 → 可能冷启动惩罚;同平台集中 → 可能该平台 baseline 偏移;跨平台多账号一致 → rubric 维度失准证据强 → 评估结论写入 `calibration/rubric-memo.md` → 在 Step 5 汇总中告知用户 + 建议(是否升级 rubric / 是否调整发布阈值)。**Agent 不得自动升级 rubric 或改阈值。** - -**Step 2 取数失败时复盘不跳过**:若某条记录 Step 2 取数失败但 DB 里已有历史互动数据(reads/likes/plays 等 > 0),复盘**必须用已有数据做**,不得因 re-fetch 失败就跳过复盘。只有 DB 里完全没有数据(全 0)且取数也失败时才跳过。 - -**如果某平台未启用 content-calibrator,跳过此步骤。Agent 不得自动启用。** +**Agent 不得自动更新 DNA**——评估建议经 Step 5 上报,用户逐条确认后走对应平台专家包的 style-dna workflow 回写。 --- @@ -176,17 +156,14 @@ 汇总执行情况,反馈用户。报告内容: 1. 各平台数据更新情况(成功/跳过/失败数量) -2. **取数端 Cookie 失效列表**(如有): - > ⚠️ 以下**取数端**Cookie 已失效,数据未能更新。请白天通知小贝重新登录: +2. **取数端登录态失效列表**(如有): + > ⚠️ 以下**取数端**登录态已失效,数据未能更新。请白天通知小贝重新登录: > - douyin(抖音) > - xhs-browse(小红书浏览端) > - wechat-channel(微信视频号) - > - > **只报告取数端 cookie**。**不要报告、也不要探测 `xhs-publish`(小红书发布端 / creator.xiaohongshu.com)**: - > 复盘/取数完全不依赖发布端 cookie,探测它只会给 creator 域增加风控概率且结论与取数无关。 - > 发布端失效由发布任务(xhs-publish 技能)自己管,不在本复盘心跳职责内。 -3. content-calibrator 复盘结果摘要(如有):列出本轮复盘的**每个作品**(`source_folder` / 标题)+ 预测 vs 实际对比简述;Step 3b 综合评估结果(`detect-bump-signals.sh` 输出的 `recommend_bump` + 触发的维度/方向/count + 混杂因素评估结论) -4. **综合评估建议(如有)**:Step 3b `recommend_bump=true` 时,Agent 输出评估结论——包含触发的维度/方向/count、证据(`examples` 里的 work/platform/dim_score/actual_score)、混杂因素评估结果、是否建议升级 rubric / 调整发布阈值。**Agent 不得自动执行升级或改阈值**。用户白天确认后,由用户发起 Rubric 升级流程(生成新公式 → 盲重打 10 篇 → `validate-rubric.sh` 验证 → pass=true 落地 / pass=false 重试最多 3 轮)。 + +3. DNA 表现评估摘要(如有):列出本轮评估的 DNA(平台 / dna-id / 覆盖篇数)+ 整体判定(改善 / 平稳 / 下滑)+ 关键归因;无触发 DNA 时写「无 DNA 达到评估阈值」并附各 DNA 待评估计数。 +4. **DNA 优化建议待确认(如有)**:列出评估报告中的逐条建议(建议内容 + 目标维度/template 部分 + 证据篇目)。**Agent 不得自动更新 DNA**。用户白天逐条确认后,指示走对应平台专家包的 style-dna workflow 回写 DNA。 5. 用户咨询回复摘要。 发送后本次定时任务结束。 diff --git a/crews/main/HEARTBEAT_TEMPLATE.md b/crews/main/HEARTBEAT_TEMPLATE.md deleted file mode 100644 index 9ab5bbc1..00000000 --- a/crews/main/HEARTBEAT_TEMPLATE.md +++ /dev/null @@ -1,242 +0,0 @@ -# HEARTBEAT_TEMPLATE - -此文件为 HEARTBEAT.md 的写入模板。当用户确认某个工作模式的配置后,参照以下格式将对应模式写入 HEARTBEAT.md。 - -**原则**:只写入用户实际启用的模式,不要预填未启用的模式。 - -> 本模板覆盖的是 main agent(小贝)的 BD / IR 两个工作条块的定时模式。新媒体运营的「每日平台数据复盘」不在此模板内——它默认已在 HEARTBEAT.md 中,由 IT engineer 设 cron 激活执行。BD / IR 模式从本模板复制到 HEARTBEAT.md 后,同样需 spawn IT engineer 设 cron。所有已启用的定时任务应在 MEMORY.md「已启用的定时任务」段登记。 - ---- - -## 商务拓展(BD) - -### 模式一:Lead Hunting(潜在客户探索) - -```markdown -### Lead Hunting(潜在客户探索) - -**状态**:已启用 - -**搜集策略**: - -**目标平台**: -- xhs:<关键词1>、<关键词2> -- dy:<关键词1>、<关键词2> -- web:<站点URL>:<搜索关键词> - -**潜在客户判定标准**: -- 策略 A(发布者画像匹配): - - 符合特征: - - <特征描述1> - - 排除特征(同行/竞对): - - <特征描述1> -- 策略 B(评论区潜客挖掘): - - 纳入评论特征: - - <特征描述1> - - 排除评论特征: - - <特征描述1> - -**执行参数**: -- 频率:<每天N次 / 每N小时> -- 每次最大探索量: -- 反馈形式:<列表报告 / Cold Touch 私信 / Email 联系>(策略 B 及 xhs 仅支持列表报告) -- Cold Touch 话术:<话术内容> -- Email 话术:<话术内容> - -**执行**:调用 `lead-hunting` 技能 -``` - -### 模式二:Comment Engagement(评论区拓展) - -> ⚠️ 小红书不支持此模式。 - -```markdown -### Comment Engagement(评论区拓展) - -**状态**:已启用 - -**目标平台**: -- dy:<关键词1> -- fb:<关键词1> - -**互动策略**: - -**互动话术**: -- <话术内容> - -**执行参数**: -- 频率:<描述> - -**执行**:调用 `comment-engagement` 技能 -``` - -### 模式三:Intel Gathering(商业情报采集) - -```markdown -### Intel Gathering(商业情报采集) - -**状态**:已启用 - -**监控信源**: -- xhs - <账号名/ID>:<监控说明> -- <网站URL>:<监控说明> - -**提取标准**: -- <要提取的信息描述> - -**交付形式**:<简报 / 报告 / 监控表格> - -**执行时间**: - -**执行**:调用 `intel-gathering` 技能 -``` - ---- - -## 投资人关系(IR) - -### 模式二:Investor Hunting(投资人搜索与触达 - 定时执行) - -```markdown -### Investor Hunting(投资人搜索) - -**状态**:已启用 - -**搜索目标**: -- 投资人类别:<天使/VC/PE/CVC/不限> -- 偏好领域:<行业/赛道> -- 地域:<国内/海外/不限> - -**搜索渠道**: -- <渠道1>:<搜索关键词> -- <渠道2>:<搜索关键词> - -**筛选标准**: -- 匹配特征: - - <特征描述1> - - <特征描述2> -- 排除特征: - - <特征描述1> - -**执行参数**: -- 频率:<每天N次 / 每N小时> -- 每次最大搜索量: -- 自动触达:<是/否> -- 触达话术:<话术内容(如启用自动触达)> - -**执行**:按 AGENTS.md IR 模式二流程执行 -``` - -### 模式三:Relationship Tracking(投资人关系维护 - 定时跟进) - -```markdown -### Relationship Tracking(关系跟踪) - -**状态**:已启用 - -**跟进规则**: -- 超过 天未跟进的活跃投资人 → 提醒用户 -- 尽调中的投资人 → 每天检查是否有更新 -- 每周一生成 Pipeline 摘要 - -**执行**: -1. 运行 ir-record 进度查询 -2. 检查是否有超期未跟进的投资人 -3. 如有新进展,更新 MEMORY.md 中的 Pipeline 表 -4. 如有需要关注的事项,汇总后推送给用户 -``` - ---- - -### IR 模式 3 巡检 - -> 投资人跟进状态机:`new → contacted → bp_sent → meeting → dd → ts → invested/passed` -> -> **7 天过期提醒**:本节新增,配合 `crews/main/skills/ir-record/scripts/query-stale.sh` 使用。 - -**触发条件**:凌晨复盘心跳 Step 2 数据抓完后,**Step 4 用户咨询回复**之前插一个 Step 2.5。 - -**Step 2.5 · 投资人过期巡检**: - -```bash -# 查 7 天无 contact 进展的投资人 -./skills/ir-record/scripts/query-stale.sh --days 7 -``` - -输出 JSON list(按 `days_since_last` 降序),每条含 `id` / `name` / `firm` / `status` / `match_score` / `last_contact_date` / `next_step` / `days_since_last`。 - -**处理规则**: -- `status` ∈ {`new`, `contacted`, `bp_sent`, `meeting`, `dd`, `ts`} 且 `days_since_last > 7` → **STALE**,加入"待跟进"列表 -- `status` ∈ {`invested`, `passed`} → **跳过**(已完结) -- `match_score` = `low` → **跳过**(非重点关注) - -**汇报**(Step 5 总报告里加一段): - -``` -## IR 巡检 -共 N 个投资人超过 7 天无进展,重点跟进: -- 张三 @ 红杉(status=meeting, 13 天无进展, last next_step=5/20 约下轮 meeting) -- 李四 @ 真格(status=bp_sent, 9 天无进展, last next_step=5/24 follow up BP) -(其他 N-K 个已完结 / 非重点,已自动跳过) -``` - -**约束**: -- 7 天阈值是**默认值**,用户可在 `ir-record/.config.json` 改(待实现) -- 凌晨不主动发起新接触(用现有 `next_step` 提醒用户白天处理) -- 不在心跳里改 `status`(用户白天自己决定推进 / 标记 passed) - ---- - -### BD 三能力巡检 - -> 配合 `lead-hunting` / `comment-engagement` / `intel-gathering`(已搬入 main/skills)+ `bd-record` / `info-record` 数据层。 -> -> **保留 heartbeat 写入模式**:本节定义 BD 的心跳触发 + 数据层写入,**不**在心跳里改用户已建档的线索状态(用户白天决定推进 / 标记 passed)。 - -**触发条件**:凌晨复盘心跳 Step 5 报告后接 Step 6(BD 巡检)。 - -**Step 6 · BD 三能力巡检**: - -| 模式 | 入口 | 数据层 | 心跳动作 | -|------|------|--------|----------| -| 模式 1 Lead Hunting | `lead-hunting` 技能 | `bd-record` 模式一表(已探索创作者) | 按用户已配置的策略 A/B + 平台 + 关键词,扫一遍最近 N 天的内容,写入 `bd-record` | -| 模式 2 Comment Engagement | `comment-engagement` 技能 | `bd-record` 模式二表(已互动帖子) | 按用户已配置的策略(direct_comment / reply_dm / direct_dm)+ 帖子清单,互动一批 → 写入 `bd-record` | -| 模式 3 Intel Gathering | `intel-gathering` 技能 | `info-record` 情报条目表 | 按用户已配置的监控信源 + 提取标准,采一遍 → 写入 `info-record` | - -**3 个模式都按 cron 周期执行**(用户配的 everyday 凌晨 3 点),而不是手动触发。心跳不发起新接触(除模式 2 互动按用户策略批跑)。 - -**初始化必问**(用户首次启用时): -- 目标平台(多选,BD 支持 xhs / 视频号 / 抖音 / 知乎等;xhs 走 `xhs-interact`,视频号走 `wechat-channels-publish`) -- 模式 1 搜集策略(A 发布者画像 / B 评论区挖掘) -- 模式 2 互动策略(direct_comment / reply_dm / direct_dm) -- 模式 3 监控信源(账号列表 / URL 列表) -- 提取标准("什么算符合目标的") -- 交付形式(简报 / 报告 / 监控表格) -- cron 表达式 - -初始化完成后,更新 HEARTBEAT.md 的本节配置,spawn IT engineer 配置定时任务。 - -**汇报**(Step 5 总报告里加一段): - -``` -## BD 巡检 -- 模式 1 Lead Hunting:扫了 X 个新内容,发现 Y 个潜在客户(已写入 bd-record) -- 模式 2 Comment Engagement:对 Z 个帖子互动(已写入 bd-record) -- 模式 3 Intel Gathering:采集 W 条情报(已写入 info-record) -(其他 0 项的模式跳过) -``` - -**约束**: -- 不主动帮用户发起 BD 接触(用户说"现在要联系 X 客户"才执行) -- 不修改 `bd-record` / `info-record` 中用户已建档的条目 -- 凌晨不扫码登录(cookie 失效 → 跳过该平台,记入 `EXPIRED_PLATFORMS`) - ---- - -## 多模式并存 - -如用户启用了多个模式,HEARTBEAT.md 中按顺序排列已启用的模式,各模式之间用 `---` 分隔。 - -## 模式禁用 - -如用户要求停用某个模式,从 HEARTBEAT.md 中删除对应配置段落,并 spawn IT Engineer 移除对应的定时任务配置。 diff --git a/crews/main/MEMORY.md b/crews/main/MEMORY.md index 35d54fbd..82f2fbd6 100644 --- a/crews/main/MEMORY.md +++ b/crews/main/MEMORY.md @@ -2,7 +2,7 @@ ## 平台策略与品牌上下文 - +见 `business_knowledge.md` ## crew 列表 @@ -11,31 +11,45 @@ - **main agent(小贝)**:DEFAULT 角色,绑 openclaw-weixin 通道 - **content-producer**:复杂内容制作crew(如专业视频制作、整体视觉输出);简单的图文海报、以及基于用户已有素材的简单视频编辑等,由main agent直接调用相关技能完成。 - **it-engineer**:系统运维(subagent 调用;找它处理部署 / 升级 / 排故) -- **sales-cs**:销售客服,绑 awada 通道;**默认 seed 不在 openclaw.json**,启用走 `sales-cs-enablement` 技能(检查 awada → channel 选择 → 派 IT engineer 配置 → 初始化AGENTS.md/IDENTITY.md/SOUL.md → 软链 `business_knowledge.md` + `business_knowledge/`);启用后的调整走 `sales-cs-review` 技能 +- **sales-cs**:销售客服,绑 awada 通道;**默认 seed 不在 openclaw.json**,启用与启用后的调整统一走 `sales-cs-manager` 专家包(Enablement workflow:检查 awada → channel 选择 → 派 IT engineer 配置 → 初始化AGENTS.md/IDENTITY.md/SOUL.md → 软链 `business_knowledge.md` + `business_knowledge/`;Review workflow:反馈复盘与话术/手册升级) - 旧版产品中的 selfmedia-operator / business-developer / designer / hrbp 全部合入main agent(小贝) -## 已启用的定时任务 +--- -> 本段登记 main agent 当前已启用的所有定时任务(cron)。**默认全部未启用**——启用需 -> spawn IT engineer 设 cron。停用时同步从本段移除并让 IT engineer 撤 cron。 -> -> 启用路径: -> - **每日新媒体平台数据复盘**:内容已在 HEARTBEAT.md 中,需 spawn IT engineer 设 -> cron 后启用。 -> - **BD / IR 定时模式**:用户确认启用某模式后,从 `HEARTBEAT_TEMPLATE.md` 复制对应 -> 段落到 `HEARTBEAT.md`,再 spawn IT engineer 设 cron。各模式 cron 表达式见 -> `HEARTBEAT.md` 中对应段的「执行时间 / 频率」。 +## 各平台运营要求 -| 任务名 | 工作条块 | cron 表达式 | 启用日期 | 状态 | -|--------|----------|-------------|----------|----------| -| _(默认空,启用后由 main agent 登记)_ | | | | | + - +--- -## Notes +## 近期宣传重点与营销活动记录 + +--- + +## Notes + +### 🚨 铁律:严格按技能流程执行,技能走不通要汇报,不自己摸索绕过 + +**必须严格遵守技能规定的流程,不能想当然绕开或自己摸索。** + +### 🚨 企业微信朋友圈只支持 JPG + +PNG 要先转(JPEG 模式 → Image.open → RGB → save quality=92)。 + +### 🚨 铁律:禁止修改 openclaw.json(2026.6.6 教训) + +**绝对禁止自己修改 `~/.openclaw/openclaw.json`(或任何 OpenClaw 系统配置文件)。** + +- 没有任何问题需要通过改 `openclaw.json` 来解决。 +- **遇到任何系统配置相关的问题**(浏览器、CDP、gateway、agent、cron 等)→ **spawn IT Engineer 解决**。 +- 这条规则没有例外,**即使看起来是个小改动**。 + +任务中遇到问题时的正确路径(按 HEARTBEAT.md 和各 skill 的 Error Handling): +1. 先彻底关闭浏览器再重新打开(默认 `openclaw` profile) +2. 不行 → spawn IT Engineer +3. 仍不行 → 跳过当前任务,继续后续步骤 +4. **绝对不能自己改 `openclaw.json`** + +### 🚨 铁律:任务遇错先看 skill 的 Error Handling(2026.6.6 教训二) diff --git a/crews/main/TOOLS.md b/crews/main/TOOLS.md index f4b0a150..972cd1b8 100644 --- a/crews/main/TOOLS.md +++ b/crews/main/TOOLS.md @@ -10,13 +10,13 @@ 具体见 `siliconflow-img-gen` 技能中 `视频封面/海报最佳实践`。 -### 数据库查询一定走 published-track 脚本 +### 数据库查询一定走 published-track wrapper -`sqlite3` 不在 allowlist 中。查询 published-track 数据库必须通过已有脚本: +`sqlite3` 不在 allowlist 中。查询 published-track 数据库必须通过 wrapper 子命令(PATH 直调): ``` -✅ ./skills/published-track/scripts/query.sh --platform wx_mp -✅ ./skills/published-track/scripts/query-pending.sh +✅ published-track query --platform wx_mp +✅ published-track query-pending ❌ sqlite3 db/published_track.db "SELECT ..." ❌ echo ".tables" | sqlite3 db/published_track.db diff --git a/crews/main/calibration/.cheat-state.json b/crews/main/calibration/.cheat-state.json deleted file mode 100644 index c90f23e3..00000000 --- a/crews/main/calibration/.cheat-state.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema_version": 3, - "scope": "global", - "rubric_version": "v0", - "mode": "cold-start", - "calibration_samples": 1, - "retro_window_days": 3, - "consecutive_directional_errors": [], - "last_bump_at": null, - "last_bump_self_audited": null, - "calibration_samples_at_last_bump": 0, - "created_at": "2026-06-14T00:00:00+08:00", - "score_threshold": 0 -} \ No newline at end of file diff --git a/crews/main/calibration/rubric-memo.md b/crews/main/calibration/rubric-memo.md deleted file mode 100644 index 93c0c076..00000000 --- a/crews/main/calibration/rubric-memo.md +++ /dev/null @@ -1,22 +0,0 @@ -# Rubric Memo — 观察记录(统一) - -> 本文件记录复盘产出的观察、实绩证据和样本引用。**全平台统一**(rubric 统一 ⇒ 观察统一)。 -> **blind sub-agent 硬禁读此文件**——它只读 rubric_notes.md。 -> rubric_notes.md 只放通用公式和维度定义,不含作品名/实绩/评论。 -> 被推翻/吸收的观察删除,git history 是档案。 - ---- - -## 观察记录 - ---- - -## Benchmark 参考 - -(导入对标账号后,对标信号记录于此。) - ---- - -## Bump 升级 Memo - -(每次 rubric 升级后,append 升级详情含证据+诊断。) diff --git a/crews/main/calibration/rubric_notes.md b/crews/main/calibration/rubric_notes.md deleted file mode 100644 index 4f2d7275..00000000 --- a/crews/main/calibration/rubric_notes.md +++ /dev/null @@ -1,77 +0,0 @@ -# Rubric Notes — 评分公式(统一) - -> **当前版本**: v0 -> **适用范围**: 全平台统一(一个作品一个打分 ⇒ 一个评分标准) -> **Last bumped at**: —(初始版本) -> **Upgrade memos**: 见 [rubric-memo.md](rubric-memo.md) -> **blind sub-agent 可读此文件**;rubric-memo / .cheat-state / audience / benchmark / 各 work 的 retro 不可读。 - ---- - -## 当前评分维度 - -7 个维度,每维 0-5 整数分。维度衡量的是**作品的内在内容质量**,与发布平台无关;平台差异体现在预测的 bucket/baseline 上,不体现在打分维度上。 - -| 维度 | 代号 | 0 分 | 5 分 | 权重 | -|------|------|------|------|------| -| 情感共鸣 | ER | 纯信息罗列,无情感触点 | 读者强烈代入"说的就是我",有具象画面或经历 | ×1.5 | -| 钩子强度 | HP | 标题平庸,开头无悬念 | 标题/开头一句话锁定注意力,制造信息差或反差 | ×1.5 | -| 社会议题共振 | SR | 纯个人/产品向,无社会讨论 | 触及当下社会讨论,有立场可议 | ×1.5 | -| 金句密度 | QL | 全文无独立可传播的表达 | ≥3 句可脱离上下文独立传播的金句 | ×1.0 | -| 叙事性 | NA | 纯观点堆砌,无故事弧线 | 清晰的起承转合,读者被故事牵引 | ×1.0 | -| 受众广度 | AB | 极窄垂直,仅特定人群关心 | 跨人群普适(如搞钱、职场、AI焦虑) | ×1.0 | -| 实用价值 | PV | 纯情绪/观点,无可操作信息 | 读者可获得具体方法/工具/步骤 | ×1.0 | - -## 综合分公式 - -``` -composite = (ER×1.5 + HP×1.5 + SR×1.5 + QL + NA + AB + PV) / 8.5 × 2.0 -``` - -- 归一化常数: 8.5 -- 缩放因子: 2.0 -- 理论范围: 0 - 10 -- 整数维度分,composite 保留两位小数 - -## Bucket 方案(按平台 baseline 派生) - -> bucket 边界**按平台**派生(各平台 baseline 量级不同),但档位定义统一。 -> cold-start 期(前 5 个作品)bucket 数字是 false precision,只给 7 维分 + 一句话 bet。 -> 第 5 个作品复盘后按实绩数据派生 bucket 边界。 - -| 档位 | 含义 | 边界(按平台 baseline) | -|------|------|---------------| -| 退步 | 低于基线 | < baseline × 0.3 | -| 持平 | 基线水平 | baseline × 0.3 ~ 1 | -| 命中 | 正常表现 | baseline × 1 ~ 3 | -| 小爆 | 超预期 | baseline × 3 ~ 10 | -| 大爆 | 现象级 | > baseline × 10 | - -各平台 baseline 见 `calibration//.platform-state.json` 的 `baseline_plays`。 - ---- - -## 版本速查 - -| 版本 | 公式签名 | 日期 | -|------|---------|------| -| v0 | ER1.5+HP1.5+SR1.5+QL+NA+AB+PV / 8.5×2 | 2026-06-14 | - ---- - -## 维度与权重变更规则 - -**维度和权重可以被修改,但必须满足以下条件之一**: -1. **用户主动要求** — "加个 XX 维度" / "把 SR 权重调到 2.0" -2. **Agent 提议 + 用户确认** — Agent 在 Bump 流程中检测到系统性偏差后提议变更,必须等待用户明确同意才生效 - -变更流程: -- 变更维度(增/删/替换)→ 走 Bump 全量重打 + 排序一致性校验 -- 变更权重 → 走 Bump 流程 -- 变更被拒绝 → rubric 不动,观察记入 rubric-memo.md - ---- - -## 待验证假设 - -(复盘后观察会写入此处,bump 时验证或推翻) diff --git a/crews/main/calibration/wx_mp/benchmark.md b/crews/main/calibration/wx_mp/benchmark.md deleted file mode 100644 index 06eb2986..00000000 --- a/crews/main/calibration/wx_mp/benchmark.md +++ /dev/null @@ -1,16 +0,0 @@ -# Benchmark — 对标账号 - -> 导入对标账号后,记录对标信号和 pattern。 -> 由 content-calibrator 的 LearnFrom 操作维护。 - ---- - -## 对标账号列表 - -(暂无。运行"导入对标"添加。) - ---- - -## Pattern 提炼 - -(从对标内容中提取的结构 pattern,如开头方式、转折技巧、金句模式等。) diff --git a/crews/main/calibration/wx_mp/rubric_notes.md b/crews/main/calibration/wx_mp/rubric_notes.md deleted file mode 120000 index 023d3c31..00000000 --- a/crews/main/calibration/wx_mp/rubric_notes.md +++ /dev/null @@ -1 +0,0 @@ -../rubric_notes.md \ No newline at end of file diff --git a/crews/main/calibration/xhs/benchmark.md b/crews/main/calibration/xhs/benchmark.md deleted file mode 100644 index 28696b97..00000000 --- a/crews/main/calibration/xhs/benchmark.md +++ /dev/null @@ -1,16 +0,0 @@ -# Benchmark — 对标账号 - -> 导入对标账号后,记录对标信号和 pattern。 -> 由 content-calibrator 的 LearnFrom 操作维护。 - ---- - -## 对标账号列表 - -(暂无。运行"导入对标 --platform xhs"添加。) - ---- - -## Pattern 提炼 - -(从对标内容中提取的结构 pattern,如封面风格、标题写法、话题标签策略、种草话术等。) diff --git a/crews/main/calibration/xhs/rubric_notes.md b/crews/main/calibration/xhs/rubric_notes.md deleted file mode 120000 index 023d3c31..00000000 --- a/crews/main/calibration/xhs/rubric_notes.md +++ /dev/null @@ -1 +0,0 @@ -../rubric_notes.md \ No newline at end of file diff --git a/crews/main/douyin/README.md b/crews/main/douyin/README.md new file mode 100644 index 00000000..840ab065 --- /dev/null +++ b/crews/main/douyin/README.md @@ -0,0 +1,14 @@ +# douyin — 抖音平台运营文件夹 + +该平台全部运营数据存于此,子目录专款专用、不混放: + +| 子目录 | 内容 | +|--------|------| +| `ref/` | 参考材料(对标视频转录、评论摘要等) | +| `outputs/` | 成片与素材(每条一个 `/`:成片、封面、简报、dna-meta.json) | +| `dna/` | DNA 运行资产(每 `/`:reports / dna 文档 / template / evals) | +| `calibration/` | 校准与复盘数据(baseline、受众画像、对标记录、平台状态) | + +`calibration/` 默认含 `audience.md`(受众画像)与 `platform-state.json`(平台状态);其余文件(对标记录等)由相应 workflow 按需生成。 + +数据存储总约定见工作区 `AGENTS.md`「数据存储」段。 diff --git a/crews/main/calibration/wx_mp/audience.md b/crews/main/douyin/calibration/audience.md similarity index 100% rename from crews/main/calibration/wx_mp/audience.md rename to crews/main/douyin/calibration/audience.md diff --git a/crews/main/douyin/calibration/platform-state.json b/crews/main/douyin/calibration/platform-state.json new file mode 100644 index 00000000..748541d7 --- /dev/null +++ b/crews/main/douyin/calibration/platform-state.json @@ -0,0 +1,13 @@ +{ + "schema_version": 3, + "scope": "platform", + "platform": "douyin", + "enabled": true, + "content_form": "短视频", + "baseline_plays": null, + "typical_word_count": null, + "enabled_perf_adapters": [ + "douyin" + ], + "created_at": "2026-08-29T00:00:00+08:00" +} diff --git a/crews/main/douyin/dna/.gitkeep b/crews/main/douyin/dna/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crews/main/douyin/outputs/.gitkeep b/crews/main/douyin/outputs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crews/main/douyin/ref/.gitkeep b/crews/main/douyin/ref/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crews/main/knowledge/channels-account-launch-expert/douyin.md b/crews/main/knowledge/channels-account-launch-expert/douyin.md deleted file mode 100644 index 4f040503..00000000 --- a/crews/main/knowledge/channels-account-launch-expert/douyin.md +++ /dev/null @@ -1,311 +0,0 @@ -# 抖音起号参考手册 - -## 使用原则 - -把起号拆成六件事:定位清楚、观看理由成立、标签稳定、内容有用、互动真实、复盘持续。来源文章里的七个技巧和 TikTok 小样本案例都可作为启发,但不要把具体数字当成保证;除非已经核验,平台功能入口、算法权重、处罚规则都按待确认信息处理。 - -默认产出要能直接执行:表格、清单、脚本简报、30天节奏或复盘动作。少写空泛建议,多给用户下一条视频该怎么做。 - -## TikTok 小样本方法论 - -这套方法来自一个“一周 9 条视频找到增长信号”的案例。迁移到抖音时,不要复制平台结论,要抽取更通用的内容判断:陌生人为什么要看、这个人为什么值得记住、哪些内容明显高于账号基线。 - -### 1. 先问观看理由 - -每条视频先回答两个问题: - -- 陌生人为什么要看完这条,而不是划走? -- 看完之后,他能记住账号的哪个身份、冲突、热情或承诺? - -普通内容改造: - -| 平铺内容 | 可看版本 | -| --- | --- | -| 展示产品很好 | 发起一个有趣的复刻、挑战、测评或对比系列 | -| 讲自己很专业 | 公开解决一个真实难题,并展示判断过程 | -| 直接教知识点 | 带着具体场景、限制条件或失败案例去解决 | -| 展示日常工作 | 把工作过程包装成任务、闯关、实验或复盘 | - -### 2. 用真实短板建立人格 - -真人或个人 IP 不必把自己包装成最厉害的人。可公开的短板、成长过程和热情,往往比完美形象更容易产生记忆点。 - -可用句式: - -- `我以前一直卡在[短板],这次想用[方法]试着解决。` -- `我不是这个领域最强的人,但我真的喜欢[主题],今天拿[任务]练一次。` -- `我做[行业/技能]时最常踩的坑是[问题],这条就把它拆开。` - -边界:不要编造悲惨经历、虚假失败、疾病、贫困、身份标签或用户案例。真实脆弱感是信任资产,不是表演道具。 - -### 3. 把内容放大 - -“放大”不是装疯卖傻,而是给普通信息增加一个更容易被看见的外壳。 - -放大方式: - -- 场景放大:在更有记忆点的环境里讲同一个知识点。 -- 动作放大:边做一件有趣的事边讲主题,例如实测、挑战、拆箱、复刻、限时完成。 -- 冲突放大:把错误现场、反差结果或限制条件放在前面。 -- 系列放大:让单条视频变成持续任务,例如“7天改造”“9条实验”“从不会到能做”。 -- 人格放大:让热情、短板、幽默感、审美或判断标准变成账号识别点。 - -检查:放大层必须服务内容本身,不能让用户只记住噱头而忘记账号价值。 - -### 4. 相对爆点不是运气 - -新号不要只看绝对播放量。先看同账号内部的相对表现:哪条明显高于其他条,哪条评论质量更高,哪条带来关注。 - -复盘字段: - -| 视频 | 播放/完播/互动 | 是否高于账号中位数 | 观看理由 | 3秒身份信号 | 人格张力 | 评论为什么喜欢 | 下一条验证 | -| --- | --- | --- | --- | --- | --- | --- | --- | - -复盘问题: - -- 这条的第一秒让用户明白“我是谁/我在做什么/哪里不一样”了吗? -- 评论里用户是在夸信息、情绪、人物、场景,还是系列设定? -- 高表现来自选题、钩子、人格、画面动作、评论争议,还是发布时间等偶然因素? -- 下一条应该复用哪个变量,只改哪个变量来验证? - -### 5. 评论是用户给出的答案 - -评论经常直接告诉你用户为什么喜欢、为什么质疑、为什么记住。不要只统计评论数,要读评论动机。 - -评论分类: - -- 喜欢内容价值:继续做同类教程、清单、案例。 -- 喜欢人物状态:放大真实表达、成长线、幽默感。 -- 喜欢形式设定:把挑战、复刻、实验、场景动作做成系列。 -- 提出具体问题:转成下一条选题。 -- 非恶意吐槽:轻松回应,增加亲和力。 -- 恶意攻击:不要对骂、挂人或扩大冲突,必要时忽略、删除或举报。 - -## 九条视频实验模板 - -适合新号、低播放号、定位需要验证的账号。目标不是保证涨粉,而是在一周左右拿到第一批可比较信号。 - -前提: - -- 9 条视频必须属于同一个定位和人群。 -- 每条只改变 1 到 2 个关键变量,例如钩子、场景、选题角度或人格表达。 -- 每条都要有明确观看理由,而不是为了凑数量发布。 - -设计表: - -| 序号 | 选题角度 | 观看理由 | 3秒钩子 | 人格/短板/热情 | 放大层 | 搜索词 | 评论问题 | 验证假设 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | 痛点教程 | | | | | | | | -| 2 | 失败复盘 | | | | | | | | -| 3 | 公开挑战 | | | | | | | | -| 4 | 案例拆解 | | | | | | | | -| 5 | 反常识观点 | | | | | | | | -| 6 | 清单避坑 | | | | | | | | -| 7 | 评论答疑 | | | | | | | | -| 8 | 场景实验 | | | | | | | | -| 9 | 系列预告/总结 | | | | | | | | - -复盘方式: - -1. 先排除违规、搬运、画质严重问题和标题误导。 -2. 计算账号内部中位数,找明显高于中位数的视频。 -3. 读高表现视频评论,标注用户喜欢的具体原因。 -4. 选择一个最可能有效的变量做下一轮 3 条验证,不要一次改完所有东西。 - -## 七模块起号框架 - -### 1. 标签反推 - -目的:让新号前期内容足够垂直,让平台和用户都能快速识别账号。 - -操作: - -1. 选 5 到 10 个对标账号,优先筛选低粉高播、近期更新、评论真实、内容形式可学习的账号。 -2. 为每个对标账号记录主标签、细分人群、核心场景、常见痛点、标题高频词、评论高频问题。 -3. 汇总成 `1个主标签 + 2到4个场景词 + 3到5个人群痛点词`。 -4. 将这些词写进昵称、简介、置顶视频、前 10 条标题、正文首句、合集名称和结尾关注理由。 - -输出表字段: - -| 对标账号 | 粉丝量级 | 高播内容 | 主标签 | 场景词 | 痛点词 | 钩子形式 | 评论信号 | 可借鉴结构 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | - -### 2. 搜索流量预埋 - -目的:让短视频不只依赖推荐流,也能长期吃搜索长尾。 - -关键词层级: - -- 核心词:赛道或问题本体,例如辅食、职场沟通、AI工具、装修避坑。 -- 长尾痛点词:用户真实搜索句,例如宝宝辅食过敏怎么办、Excel自动汇总怎么做。 -- 场景词:时间、人群、空间、预算、身份,例如上班族、租房、小白、6月龄、第一次。 -- 转化词:清单、步骤、模板、避坑、对比、测评、教程、案例。 - -标题模板: - -- `[人群/场景] + [痛点] + [解决结果]` -- `[核心词]别急着做,先看这[数量]个坑` -- `我用[方法]帮[人群]解决了[具体问题]` - -正文埋词: - -- 首句放长尾问题,直接回应用户搜索意图。 -- 中段用步骤、案例或清单证明内容有用。 -- 结尾引导评论下一个相关问题,形成下一条内容的搜索词。 - -### 3. 3秒钩子与完播 - -目的:让用户在最初几秒知道“这和我有关,而且值得看完”。 - -钩子类型: - -- 矛盾前置:先展示反常识结果或错误现场,再解释原因。 -- 数据冲击:使用可解释的数据或样本,不夸大来源。 -- 场景代入:直接喊出具体人群和具体处境。 -- 悬念留白:告诉用户后面有清单、步骤或结果,但正文必须兑现。 -- 结果反差:先给前后对比,再拆过程。 - -检查标准: - -- 钩子是否对应账号定位。 -- 钩子是否能在正文里兑现。 -- 是否为了点击牺牲信任。 -- 画面、字幕、口播是否在同一秒传递同一个重点。 - -### 4. 评论暗号与互动 - -目的:用价值承接互动,而不是机械要求点赞关注。 - -合规表达: - -- `评论你的情况,我挑3个做下一条。` -- `想要清单可以评论关键词,我整理成下一条。` -- `你遇到的是A还是B?评论区我帮你判断。` -- `这个系列会持续更新,关注后方便找到下一集。` - -避免表达: - -- `点赞关注才发。` -- `不关注不给。` -- `私信暗号绕过平台。` -- `复制粘贴同一句评论刷互动。` - -将评论转成内容: - -| 评论问题 | 所属痛点 | 是否高频 | 下一条选题 | 搜索关键词 | 是否进合集 | -| --- | --- | --- | --- | --- | --- | - -### 5. 私域冷启动 - -目的:用真实相关的人群帮新视频获得第一批有效反馈,不制造虚假互动。 - -分层触达: - -- 高信任朋友:请对方判断内容是否有用,重点要反馈。 -- 垂直社群:分享一个具体问题的解决方法,不刷屏、不要求统一互动。 -- 老客户/读者:邀请补充真实问题或案例,作为后续内容素材。 - -可用话术: - -- `我做了一条[人群]会遇到的[问题]短视频,想请你帮我看一下:它有没有讲清楚?` -- `这条是给[场景]的人看的,如果你身边有人正在遇到这个问题,可以转给他。` -- `我在收集下一条选题,你觉得这个问题里最容易踩坑的是哪一步?` - -底线:不买量、不互刷、不群控、不强制点赞评论、不打扰无关人群。 - -### 6. 合集滚雪球 - -目的:让账号垂直度、用户连续观看和复访更明确。 - -适合做合集的内容: - -- 同一人群的连续问题。 -- 同一能力的入门到进阶。 -- 同一场景的多步骤教程。 -- 同一产品或服务的系列答疑。 - -合集命名: - -- `[人群] + [价值] + [数量]` -- `[场景] + [从入门到进阶]` -- `[痛点]避坑合集` - -排序:先解决最常见问题,再进入进阶问题;每条结尾提示下一集解决什么。 - -### 7. 数据校准 - -目的:用数据定位问题发生在哪一环,而不是凭感觉大改账号。 - -诊断表: - -| 信号 | 可能问题 | 优先动作 | -| --- | --- | --- | -| 曝光低 | 账号标签弱、内容垂直度不足、违规风险、样本太少 | 收窄主题,查风险词,连续发布同一内容支柱 | -| 点击低 | 封面/标题没说清人群和收益 | 重写标题,首帧突出痛点和结果 | -| 完播低 | 钩子虚、节奏慢、信息密度不均 | 前3秒改冲突,中段改步骤,删空话 | -| 评论少 | 缺少可回答问题,内容没有争议点或共鸣点 | 结尾问具体问题,承接真实评论 | -| 关注少 | 账号承诺不清,单条有用但系列价值弱 | 加强主页、置顶、合集和结尾关注理由 | -| 搜索少 | 标题和正文缺少长尾词 | 补关键词地图,做搜索型选题 | -| 记不住人 | 身份信号弱、人格张力弱、内容太像资料搬运 | 加入真实场景、短板、判断标准或系列任务 | -| 评论质量低 | 引导过浅或吸引错人 | 改成场景问题,减少泛福利引导 | - -阈值只作为经验提示,不能机械判断。样本太小时,先收集 10 到 20 条同类内容再做大调整。 - -## 视频简报模板 - -```markdown -# 视频简报 - -- 账号定位: -- 内容支柱: -- 目标人群: -- 观看理由: -- 人格张力: -- 用户痛点: -- 搜索关键词: -- 标题备选: -- 3秒钩子: -- 放大层: -- 正文结构: - 1. - 2. - 3. -- 画面/证据: -- 评论引导: -- 合集归属: -- 风险检查: -- 发布后重点观察: -``` - -## 30天起号节奏 - -第 1 到 3 天:定定位、做对标、建关键词地图、准备首批 10 个选题。 - -第 4 到 10 天:连续发布同一内容支柱下的 7 到 9 条视频,重点观察观看理由、点击、完播、评论动机和关注理由。 - -第 11 到 17 天:挑选高于账号中位数的主题做系列化,补 3 条搜索型内容,开始整理合集。 - -第 18 到 24 天:放大高质量评论,做答疑、清单、案例、避坑类内容,优化主页和置顶。 - -第 25 到 30 天:复盘内容支柱,保留有效格式,淘汰弱假设,形成下一个 30 天内容日历。 - -## 合规替代表 - -| 高风险做法 | 风险 | 合规替代 | -| --- | --- | --- | -| 点赞关注才给资料 | 诱导互动、伤害信任 | 评论问题后公开做下一条或合集 | -| 搬运爆款脚本换词 | 版权和原创风险 | 提取结构,用自己的案例和画面重写 | -| 私域群统一点赞评论 | 人为干预、数据失真 | 请真实相关人群给反馈或补充问题 | -| 夸大收益或效果 | 虚假宣传风险 | 明确适用条件、样本限制和不确定性 | -| 隐藏联系方式绕规则 | 平台处罚风险 | 使用平台允许的主页、店铺、私信和企业号能力 | -| 未标注AI生成内容 | 规则与信任风险 | 按平台现行规则标注,并加入人工审核 | -| 编造脆弱故事 | 信任崩塌和虚假人设风险 | 只使用真实可公开的成长过程和短板 | -| 借吐槽攻击观众 | 引战和账号形象风险 | 轻松化解非恶意吐槽,恶意攻击则忽略或处理 | - -## 常用产物字段 - -起号计划字段:账号目标、定位句、观看理由、人格记忆点、目标人群、内容支柱、关键词地图、主页文案、置顶视频、前30条选题、发布节奏、复盘指标、风险边界。 - -账号审计字段:当前定位、主页信号、内容垂直度、观看理由、标题/封面、钩子、搜索词、人格张力、评论质量、合集、合规风险、优先修复动作。 - -周复盘字段:本周发布、最高信号、最低信号、有效钩子、有效关键词、有效人格信号、评论素材、失败假设、下周实验、停做事项。 \ No newline at end of file diff --git a/crews/main/knowledge/channels-account-launch-expert/twitter_x.md b/crews/main/knowledge/channels-account-launch-expert/twitter_x.md deleted file mode 100644 index 9e794d4d..00000000 --- a/crews/main/knowledge/channels-account-launch-expert/twitter_x.md +++ /dev/null @@ -1,111 +0,0 @@ -# X/Twitter 冷启动框架 - -此参考来自“16 天跑到 500 粉”的方法论提炼,只保留可复用流程,不复刻原文表达。 - -## 核心判断 - -冷启动阶段不要把 X 当朋友圈,也不要当公众号。它更像开放广场:陌生人会快速扫过你的头像、简介、主贴和回复。所有动作都服务一个问题:陌生人看到你 3 秒钟,凭什么停下来? - -## 诊断问题 - -尽量先收集这些信息。若用户没有提供,不要卡住,先用合理假设输出方案,并列出待补充项。 - -| 维度 | 要问什么 | 诊断目的 | -| --- | --- | --- | -| 当前阶段 | 粉丝数、发帖频率、账号创建时间 | 判断是 0 起步、低反馈期,还是定位混乱期 | -| 目标读者 | 最想服务谁,他们正在卡什么 | 避免账号变成泛泛记录 | -| 关键词 | 3 个持续输出关键词 | 让别人快速记住账号 | -| 真实工作流 | 最近在用什么工具、流程、模板、项目 | 从真实经验里长出内容 | -| 内容数据 | 哪些主贴、回复、引用转发有曝光或关注 | 区分有效信号和虚假热闹 | -| 时间预算 | 每天可投入多少分钟 | 设计可持续节奏 | -| 边界 | 不想做什么增长动作 | 避免互关、抽奖、硬蹭热点等偏航动作 | - -## 定位公式 - -用这四步收窄账号: - -1. 选 3 个关键词:例如“AI 工作流 / 创作者效率 / 自动化实战”。 -2. 写目标读者:例如“想把 AI 工具用于日常内容生产的普通创作者”。 -3. 写关注理由:例如“关注我,你会持续拿到可复用的工具流程、踩坑复盘和模板清单”。 -4. 写边界:例如“不做泛 AI 新闻搬运,不做情绪鸡血,不靠抽奖互关增长”。 - -输出模板: - -```text -我持续围绕「关键词1、关键词2、关键词3」输出, -主要帮「目标读者」解决「长期问题」。 -别人关注我的理由是:「能定期拿走什么」。 -我暂时不做:「边界」。 -``` - -## 内容系统 - -优先从真实工作流里找内容,不要为了发帖硬憋选题。 - -| 内容类型 | 来源 | 输出方式 | -| --- | --- | --- | -| 工具实测 | 今天真的试过的工具、模型、插件、自动化 | 主贴、截图拆解、优缺点 | -| 流程复盘 | 跑通或失败的一套流程 | Thread、清单、前后对比 | -| 踩坑记录 | 报错、误判、无效动作、配置问题 | 小主贴、回复补充 | -| 模板资产 | 表格、prompt、工作流、skill、文档 | 置顶帖目录、下载/复用说明 | -| 观点判断 | 基于实践得出的判断 | 引用转发、短帖 | -| 互动升级 | 高质量回复被看见后 | 扩写成主贴或 Thread | - -主贴负责沉淀资产,回复负责让新人发现你。每周至少把 1 条高质量回复扩成主贴或 Thread。 - -## 回复质量公式 - -一条好回复包含: - -1. 一句真实反应。 -2. 一个具体场景。 -3. 一个补充判断或可继续聊的问题。 - -模板: - -```text -这个点对「具体人群/场景」很有用。 -我在「具体工作流/工具/项目」里也遇到过类似问题: -「补充一个细节、结果或坑」。 -我会继续看「一个后续问题/判断」,因为它决定了「影响」。 -``` - -差回复只表达情绪,例如“太强了”“学到了”。好回复要让陌生人不点进主页也学到一点东西。 - -## 7 天冷启动计划 - -| 天数 | 目标 | 动作 | 产出物 | -| --- | --- | --- | --- | -| 第 1 天 | 缩窄定位 | 写 3 个关键词、目标读者、关注理由、边界 | 定位句、简介草稿 | -| 第 2 天 | 找同领域信号 | 找 20 个同领域账号,记录他们最近常聊什么 | 账号观察表、10 个选题 | -| 第 3 天 | 练回复曝光 | 写 3 条认真回复,每条补具体经验 | 3 条可独立阅读的回复 | -| 第 4 天 | 回复转主贴 | 选 1 条高质量回复扩成主贴 | 1 条主贴 | -| 第 5 天 | 工作流变内容 | 整理一个真实工作流,哪怕很小 | 流程清单或截图说明 | -| 第 6 天 | 做案例帖 | 写一个前后变化、踩坑或结果复盘 | 案例帖或 Thread | -| 第 7 天 | 数据复盘 | 看曝光、互动、主页访问、关注转化 | 下周调整表 | - -## 周复盘表 - -| 内容/动作 | 曝光 | 互动 | 主页访问 | 新关注 | 判断 | 下一步 | -| --- | --- | --- | --- | --- | --- | --- | -| 主贴 A | | | | | 是否带来关注 | 扩写/复用/放弃 | -| 回复 B | | | | | 是否只是热闹 | 改成主贴/改写角度 | -| 引用转发 C | | | | | 是否有观点增量 | 继续追踪/沉淀 | - -复盘时重点看“哪些内容带来关注”,不是只看曝光。高曝光但无关注,通常说明内容没有承接到账号定位,或回复没有营养。 - -## 主页承接清单 - -- 简介是否一眼看出你服务谁。 -- 置顶帖是否说明未来会持续分享什么。 -- 置顶帖下是否有至少 5-10 个代表内容链接或目录。 -- 最近 10 条内容是否围绕同一个小领域。 -- 是否能看出你有真实实践,而不是只转述观点。 - -## 风险边界 - -- 不建议把冷启动押在一条爆款上。 -- 不建议靠抽奖、互关、无关热点换短期数字。 -- 不建议只发主贴而不去高质量回复区露面。 -- 不建议把自动化用于垃圾互动、批量灌水或违反平台规则的动作。 -- 不建议未经核验就给出 X/Twitter 最新规则、API 限制或自动化安全承诺。 \ No newline at end of file diff --git a/crews/main/knowledge/channels-account-launch-expert/wx_channel.md b/crews/main/knowledge/channels-account-launch-expert/wx_channel.md deleted file mode 100644 index 0b78d968..00000000 --- a/crews/main/knowledge/channels-account-launch-expert/wx_channel.md +++ /dev/null @@ -1,351 +0,0 @@ -# 视频号起号参考手册 - -## 使用原则 - -把起号拆成六件事:定位清楚、人设可信、内容有用、互动真实、承接有路、复盘持续。来源文章里的涨粉数据、推荐权重、挂车转化都可作为启发,但不要把具体数字当成保证;除非已经核验,平台功能入口、社交分发机制、处罚规则都按待确认信息处理。 - -默认产出要能直接执行:表格、清单、脚本简报、30天节奏或复盘动作。少写空泛建议,多给用户下一条视频该怎么做。 - -本手册适用于视频号各类起号目标:个人 IP、企业品牌/品宣、电商带货、私域引流。 - -> **标注约定**:本手册中标注「经验假设」的内容来自第三方运营文章(2025–2026 年视频号运营复盘、平台算法白皮书解读、电商起号攻略),属于可参考的行业经验,非微信官方保证值。涉及平台现行规则、挂车/直播门槛、广告与处罚边界等变化较快事项,给最终操作建议前先核验官方规则,或在输出中明确“该建议基于未核验经验假设”。 - ---- - -## 推荐机制详解(联网核验要点 · 经验假设) - -视频号的核心差异化是「社交推荐 + 算法推荐」双引擎,且社交权重大于纯算法平台。理解机制的目的,是让内容先拿到私域初始信号、再用社交信任背书撬动公域。 - -### 1. 三级传播模型(私域撬动公域) - -视频号能实现「100 私域 → 10 万公域」的跃迁,靠的是社交关系的逐级放大: - -- **一级传播**:用户点赞触发约 20% 微信好友曝光;评论会触发「朋友热议」标签,显著提升同圈层点击。 -- **二级传播**:转发行为使视频覆盖约 50% 直接好友 + 30% 延伸社交圈(朋友的朋友)。 -- **三级传播**:当视频在私域获得持续互动(如收藏率 > 3%),算法将其纳入「社交热点池」,通过「朋友都在看」标签实现跨圈层扩散。 - -> 实操含义:新号没声量时,主动发动真实私域做「点赞-评论-转发」三连,是性价比最高的冷启动动作;但必须面向真实相关人群,禁止互刷群控。 - -### 2. 多模态内容理解 - -视频号用「文本 + 视觉 + 音频」三维特征提取来判定内容价值: - -- **文本(NLP)**:解析标题、字幕关键词,识别「痛点-解决方案」结构(如「30 岁转行 IT 的 5 个陷阱」)。 -- **视觉**:检测画面主体,自动生成场景标签(如「职场穿搭」「露营装备」)。 -- **音频**:捕捉语音情绪,正能量/励志类内容可获得额外权重。 - -> 实操含义:标题与字幕要把「人群 + 痛点 + 方案」写清楚;画面主体要稳定可识别,别让镜头乱切导致标签漂移。 - -### 3. 实时价值评分 - -算法对内容做实时打分,分层看: - -- **短期价值**:3 秒播放率(经验阈值 > 50% 进入推荐池)、完播率(经验阈值 > 45% 触发二次推流)。 -- **长期价值**:复访率(7 天内重复观看)、社交传播系数(转发带来的新用户占比)。 -- **生态价值**:内容垂直度(前 5 条视频决定初始标签)、账号活跃度(周更 3 条以上可获流量倾斜)。 - -> 实操含义:前 5 条必须垂直打透一个定位;别断更,起号期保持周更 3–5 条。 - -### 4. 社交分享 > 点赞 - -视频号的逻辑是「朋友赞过的内容」拥有极高权重,但**分享(转发到朋友圈/群聊)比点赞更核心**——分享代表用户用社交信用为你担保,会触发更高阶的流量池。判断内容健康度时可交叉看「完播 × 分享」: - -- 完播高、分享低 → 内容好看但缺社交价值(不好用、不值得转),需加实用/情绪/人设价值点。 -- 分享高、完播低 → 标题党或开头虚,需补内容质量。 - -### 5. 长尾流量效应 - -不同于其它平台内容热度通常只维持 3–7 天,视频号的优质内容凭借社交裂变,可在发布数月后仍持续获得曝光与涨粉。一次创作,长期收益——这也意味着「合集化、系列化」内容在视频号更值钱。 - -### 6. 微信全链路闭环 - -视频号与朋友圈、公众号、社群、企业微信、小程序无缝打通,形成「内容曝光 → 粉丝关注 → 私域沉淀 → 商业转化」闭环。公众号插入视频可为视频号导流;视频号主页可挂公众号/企微;直播可沉淀企微社群,社群再为后续内容提供初始互动——形成「私域撬公域、公域反哺私域」的正向循环。 - ---- - -## 起号打法:私域热启动 + 冷启动 6 渠道 - -### 私域热启动(发布后第一动作) - -新号发布后立刻在 10 个左右核心社群/好友中引导「点赞-评论-转发」三连,触发「好友互动标签」,让算法把内容推进更大流量池。话术要真实:请对方判断“这条对不对、有没有用”,而不是统一刷互动。 - -### 冷启动 6 大推广渠道 - -1. **朋友圈**:转发必带文案,文案可套四类——①共鸣+需求+利益点 ②热点+观点+引导讨论 ③数据成果+知识点+利益引导 ④痛点问题+解决办法+引导观看。 -2. **微信社群**:内容分发 + 产品/服务导入 + 复购提升;分享必带文案、遵守群规;互赞群秒看秒赞易被判营销,可把群成员发展成真实私域好友。 -3. **公众号**:有公众号可直接把视频插入文章推送(一个公众号只能绑定一个同主体视频号);也可做 1 对 1 互推或多对多互推涨粉。 -4. **1 对 1 私聊**:核心目的是培养观看习惯和收集修改意见,话术结构=尊称+点明来意/引发好奇+感谢。 -5. **视频号大号评论区**:在热门/大号作品下用「短视频号博主身份」留短句神评输出观点,方便用户点头像进主页;发完给自己的评论点赞。**切勿长篇硬广。** -6. **同城推荐**:适合有实体门店的商家,上传时打开定位即可获得同城流量,追求交易闭环而非泛流量。 - -### 前 3 秒黄金法则 - -- 封面三要素:身份 + 痛点 + 解决方案(如「宝妈|月入 5 千到 2 万的副业」)。 -- 前 2 秒直接抛冲突(「你以为副业只有带货?」),第 3 秒预告价值(「3 个零成本技能变现方法」)。 -- 视频号用户平均滑动速度比其它短视频平台快约 30%,3 秒抓不住就被划走;建议 3 秒一变画面、15 秒一个小高潮。 - -### 用户转发的三种底层动机(决定能否破圈) - -- **实用价值**:能帮自己/家人留存有用信息(如「3 句话化解孩子叛逆」家长会转家长群)。 -- **情绪态度**:能帮用户表达自身心声(情感共鸣类)。 -- **社交人设**:能帮用户塑造想呈现的社交形象(正能量、专业、有品位)。 - -> 写脚本时至少命中一种转发动机,配一句明确的转发引导(「转给家里有宝宝的朋友,避免踩坑」)。 - -### 互动设计 - -结尾用「选择题」引导评论比开放式提问更好(如「小户型选投影仪还是电视?」),可显著提升评论率。评论区要及时回复、做软性关注引导,提升活跃度并让算法判定为优质内容。 - -### 定位三维坐标系(电商/带货号尤其适用) - -起号前先解决「为谁拍 × 凭什么信你 × 用什么标签记住你」: - -- **用户画像**:用「场景拆解法」锁定买单人群——核心场景(用户在什么情境需要你)、核心痛点、决策关键。想讨好所有人 = 标签混乱 = 起号失败。 -- **人设标签**:选 1–2 个核心关键词(如「专业靠谱 + 幽默接地气」),遵循「三度原则」:专业度(知识输出)、可信度(展示真实场景、坦然提缺点)、亲近度(生活化语言替代广告话术)。 -- **商品匹配**:起号期选品满足「三有标准」——有场景感、有视觉点、有冲动性(建议单价 50–200 元 + 限时/赠品);避开无差异化、价格无优势、供应链不稳三坑。 - ---- - -## 爆款内容公式 - -### 通用钩子类型(用于前 3 秒) - -- 矛盾前置:先展示反常识结果或错误现场,再解释原因。 -- 数据冲击:使用可解释的数据或样本,不夸大来源。 -- 场景代入:直接喊出具体人群和具体处境。 -- 悬念留白:告诉用户后面有清单/步骤/结果,但正文必须兑现。 -- 结果反差:先给前后对比,再拆过程。 - -### 电商带货 6 类高转化素材(经验假设,可套用) - -| 类型 | 公式 | 适用 | 关键点 | -| --- | --- | --- | --- | -| 痛点解决型 | 1秒痛点场景 + 3秒问题放大 + 10秒解决方案 + 2秒行动引导 | 通用,最强流量入口 | 痛点要具体(不说「做饭麻烦」,说「切洋葱流泪」);方案与商品强绑定 | -| 场景植入型 | 日常场景开场 + 自然使用展示 + 细节价值凸显 + 隐性引导 | 家居/服饰/食品等生活化 | 真实可复制、商品出现不突兀、突出场景价值 | -| 专业测评型 | 测评主题明确 + 多维对比 + 核心卖点突出 + 真实结论 | 美妆/数码/家电 | 维度贴近需求、对比对象有代表性、专业度可视化(数据/拆解画面) | -| 效果对比型 | 问题状态 + 过程快进 + 效果惊喜 + 原理补充 | 美妆/清洁/家居改造 | 对比真实、变化细节放大、强调非一次性效果 | -| 开箱体验型 | 拆箱仪式感 + 细节展示 + 真实试用 + 总结推荐 | 新品/质感占优商品 | 保持未知感、突出细节价值、含避坑提醒更真诚 | -| 私域引导型 | 福利抛出 + 添加理由 + 操作路径 + 后续价值预告 | 长效复购 | 福利真实有吸引力、路径尽量一步、后续价值清晰 | - -> 带货号建议先选 2–3 类适合自己的素材深耕,保持周更 3–5 条,用「价值传递」替代「广告推销」心态。 - ---- - -## 一、账号定位与人设 - -### 定位句 - -`我帮助[目标人群],用[独特优势/内容价值]解决[具体痛点],让他们获得[理想结果]。` - -### 人设三标签 - -从下面维度各取一个,组合成记忆点: - -- 身份标签:老板/创始人/主理人/专家/源头供应链/品牌。 -- 特质标签:直爽、专业、抠细节、敢说真话、老行家、有审美。 -- 价值标签:源头价、避坑、实测、不踩坑指南、行业内幕。 - -### 人设记忆点检查 - -- 身份信号是否在前 3 秒就让用户知道“我是谁、我在做什么、哪里不一样”? -- 是否有真实可公开的短板、成长线或踩坑,而不只是完美形象? -- 人设是否和出镜者或品牌一致,不会硬凹翻车? - -品牌号要点:提炼“品牌人格”——说话语气、价值主张、固定视觉,让账号像一个有调性的人。 - ---- - -## 二、内容矩阵与选题库 - -### 三类内容比例(可按目标微调) - -| 类型 | 比例 | 内容示例 | -| --- | --- | --- | -| 人设/信任类 | 40% | 故事、干货、踩坑复盘、幕后实拍 | -| 种草/价值类 | 40% | 使用场景、对比测评、客户案例、源头优势 | -| 转化/互动类 | 20% | 限时福利、直播预告、答疑、清单 | - -带货号可提高种草与转化比例;品宣/个人IP号可提高人设与干货比例。 - -### 选题库字段 - -| 选题 | 人群痛点 | 关键词 | 钩子 | 内容承诺 | 拍摄素材 | 行动引导 | 风险检查 | -| --- | --- | --- | --- | --- | --- | --- | --- | - -前 20 条按“易拍优先”排序,1 周内拍完做数据筛选;新号前 20 条保持垂直。 - ---- - -## 三、视频脚本结构 - -每条视频先写简报,再写脚本。脚本结构: - -1. **3秒钩子(0-3s)**:反常识 / 痛点提问 / 利益前置,留住划走的手指。 -2. **痛点(3-10s)**:替用户说出难处,建立共鸣。 -3. **信任状(10-20s)**:身份、资历、真实案例/数据,解决“凭啥信你”。 -4. **卖点/价值(20-40s)**:差异化 1-3 个,用对比/演示讲清楚。 -5. **促单/关注引导(最后 5-10s)**:明确行动指令(关注/点赞/购物车/私信/预约直播)。 - -脚本标注字段:时长、景别、口播词、画面/字幕、BGM。真人出镜占比建议 ≥ 60%,避免全程配音。 - ---- - -## 四、承接路径(按账号目标) - -- 带货号:视频 → 评论区置顶引导(私信/主页链接)→ 私信话术(发资料/留资)→ 直播/小店成交 → 企微/社群复购。 -- 品宣/引流号:视频 → 主页/合集 → 企微/社群沉淀。 -- 个人IP号:视频 → 关注/合集 → 直播或私域长期经营。 - -合规表达: - -- `想要清单可以评论关键词,我整理成下一条。` -- `你遇到的是A还是B?评论区我帮你判断。` -- `这个系列会持续更新,关注后方便找到下一集。` - -避免表达: - -- `点赞关注才发。` `不关注不给。` -- `私信暗号绕过平台。` `复制粘贴同一句评论刷互动。` - -挂车 / 直播遵守平台商品与广告规范,不隐藏违规联系方式、不夸大功效。 - ---- - -## 五、私域冷启动 - -目的:用真实相关的人群帮新视频获得第一批有效反馈,不制造虚假互动。 - -分层触达: - -- 高信任朋友 / 老客户:请对方判断内容是否有用,重点要反馈。 -- 垂直社群 / 企微:分享一个具体问题的解决方法,不刷屏、不要求统一互动。 -- 朋友圈:本人或品牌发视频并说清“这条是给谁看的”,邀请真实人群观看。 - -底线:不买量、不互刷、不群控、不强制点赞评论、不打扰无关人群。 - ---- - -## 六、九条视频实验模板 - -适合新号、低播放号、定位需要验证的账号。目标不是保证涨粉,而是在一周左右拿到第一批可比较信号。 - -前提: - -- 9 条视频必须属于同一个定位和人群。 -- 每条只改变 1 到 2 个关键变量,例如钩子、场景、选题角度或人设表达。 -- 每条都要有明确观看理由,而不是为了凑数量发布。 - -设计表: - -| 序号 | 选题角度 | 观看理由 | 3秒钩子 | 人设/短板/热情 | 放大层 | 评论问题 | 验证假设 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | 痛点教程 | | | | | | | -| 2 | 失败复盘 | | | | | | | -| 3 | 公开挑战 | | | | | | | -| 4 | 源头/幕后探店 | | | | | | | -| 5 | 反常识观点 | | | | | | | -| 6 | 清单避坑 | | | | | | | -| 7 | 客户案例 | | | | | | | -| 8 | 直播/福利预告 | | | | | | | -| 9 | 系列总结 | | | | | | | - -复盘方式: - -1. 先排除违规、搬运、画质严重问题和标题误导。 -2. 计算账号内部中位数,找明显高于中位数的视频。 -3. 读高表现视频评论,标注用户喜欢的具体原因。 -4. 选择一个最可能有效的变量做下一轮 3 条验证,不要一次改完所有东西。 - ---- - -## 七、数据校准 - -### 关键指标阈值(经验假设,非官方保证) - -| 指标 | 经验健康线 | 优秀线 | 说明 | -| --- | --- | --- | --- | -| 完播率 | ≥ 30%(低于即开头/节奏问题) | ≥ 45–50% | 算法判断优质的第一核心指标 | -| 互动率(转评赞/播放) | ≥ 5% | 8%+ | 低于即缺共鸣点 | -| 购物车点击率(点击/播放) | ≥ 2% | — | 低于即商品与内容不匹配或引导不足 | -| 转化率(下单/点击) | ≥ 3% | 5%+ | 低于即卖点/信任/价格问题 | - -> 阈值只作为经验提示,不能机械判断。样本太小时,先收集 10–20 条同类内容再做大调整。 - -### 诊断表(视频号「社交 + 算法」双引擎视角) - -| 信号 | 可能问题 | 优先动作 | -| --- | --- | --- | -| 曝光低 | 账号标签弱、内容垂直度不足、社交启动不够、违规风险 | 收窄主题,发动真实私域看转赞,连续发布同一内容支柱 | -| 完播低 | 钩子虚、节奏慢、信息密度不均 | 前3秒改冲突,中段改步骤,删空话 | -| 转评赞少 | 缺少可回应点,内容没有共鸣或争议点 | 结尾问具体问题,承接真实评论 | -| 分享率低 | 内容缺社交价值(不好用/不值得转) | 加实用/情绪/人设价值点,配转发引导 | -| 私信少 | 缺少留资钩子或行动指令不清 | 加评论区置顶引导与私信话术 | -| 关注少 | 账号承诺不清,单条有用但系列价值弱 | 加强主页、置顶、合集和结尾关注理由 | -| 成交少 | 承接路径断、信任状弱、促单模糊 | 强化信任状与限时促单,理顺挂车/直播路径 | -| 记不住人 | 身份信号弱、人设张力弱 | 加入真实场景、短板、判断标准或系列任务 | - -爆款复制与迭代:当某条数据突出(如完播 > 50%、转化 > 5%),拆解「素材类型/开头钩子/核心卖点/场景设置/引导方式」,保持核心框架换商品或细节做迭代。 - ---- - -## 视频简报模板 - -```markdown -# 视频简报 - -- 账号定位: -- 内容支柱: -- 目标人群: -- 观看理由: -- 人设张力: -- 用户痛点: -- 3秒钩子: -- 信任状: -- 卖点/价值: -- 促单/关注引导: -- 画面/证据: -- 评论引导: -- 承接路径: -- 风险检查: -- 发布后重点观察: -``` - ---- - -## 30天起号节奏 - -第 1 到 3 天:定定位(三维坐标系)、做对标、提炼人设三标签、准备首批 10 个选题、装修主页。 - -第 4 到 10 天:连续发布同一内容支柱下的 7 到 9 条视频,发布后立即做私域热启动(真实好友/社群点赞-评论-转发三连),重点观察钩子、完播、分享动机。 - -第 11 到 17 天:挑选高于账号中位数的主题做系列化,按目标补转化/品宣内容,开始整理合集;带货号可启动挂车/直播预热。 - -第 18 到 24 天:放大高质量评论、做答疑/清单/案例/幕后,优化主页与置顶,跑通承接路径,沉淀企微/社群。 - -第 25 到 30 天:复盘内容支柱与指标阈值,保留有效格式,淘汰弱假设,形成下一个 30 天内容日历与直播/发布节奏。 - ---- - -## 合规替代表 - -| 高风险做法 | 风险 | 合规替代 | -| --- | --- | --- | -| 点赞关注才给资料 | 诱导互动、伤害信任 | 评论问题后公开做下一条或合集 | -| 搬运爆款脚本换词 | 版权和原创风险 | 提取结构,用自己的案例和画面重写 | -| 私域群统一点赞评论 | 人为干预、数据失真 | 请真实相关人群给反馈或补充问题 | -| 夸大收益或功效 | 虚假宣传风险 | 明确适用条件、样本限制和不确定性 | -| 隐藏联系方式绕规则 | 平台处罚风险 | 使用平台允许的主页、店铺、私信和企业号能力 | -| 未标注AI生成内容 | 规则与信任风险 | 按平台现行规则标注,并加入人工审核 | -| 编造脆弱故事 | 信任崩塌和虚假人设风险 | 只使用真实可公开的成长过程和短板 | -| 借吐槽攻击观众 | 引战和账号形象风险 | 轻松化解非恶意吐槽,恶意攻击则忽略或处理 | -| 叫卖式硬广/品牌过度露出 | 被判营销内容、限流 | 用「亲测好用」替代「赶紧买」,弱化品牌露出,非营销内容占比 > 70% | - ---- - -## 常用产物字段 - -起号计划字段:账号目标、定位句、人设三标签、内容矩阵、关键词地图、主页文案、置顶视频、前30条选题、发布节奏、直播/挂车节奏、复盘指标、风险边界。 - -账号审计字段:当前定位、主页信号、内容垂直度、观看理由、标题/封面、钩子、人设张力、评论质量、承接路径、合集、合规风险、优先修复动作。 - -周复盘字段:本周发布、最高信号、最低信号、有效钩子、有效人设信号、评论素材、成交数据、失败假设、下周实验、停做事项。 \ No newline at end of file diff --git a/crews/main/knowledge/channels-account-launch-expert/wx_mp.md b/crews/main/knowledge/channels-account-launch-expert/wx_mp.md deleted file mode 100644 index e70d5616..00000000 --- a/crews/main/knowledge/channels-account-launch-expert/wx_mp.md +++ /dev/null @@ -1,271 +0,0 @@ -# 微信公众号起号操作手册 - -这份参考手册把本地源文中的公众号起号经验提炼成可复用、合规优先的操作流程。平台发布时间、推荐池、标签、收益等说法都按经验假设处理,不视为官方或实时规则。 - -## 1. 需求采集表 - -用户要求起号计划、账号审计、选题库或文章系统时使用。 - -| 字段 | 询问或推断 | -| --- | --- | -| 目标 | IP信任、流量主收益、服务线索、产品销售、付费社群、通讯专栏或测试 | -| 赛道 | 类目、细分类目、首月最窄切入口 | -| 读者 | 谁有痛点、好奇心、消费能力或转发动机 | -| 变现 | 账号最终要卖什么、验证什么或沉淀什么 | -| 证明 | 案例、资质、经验、截图、用户问题、研究笔记、故事素材 | -| 产能 | 每周文章数、资料深度、编辑支持、首轮冲刺周期 | -| 阶段 | 新号、沉寂号、内容不稳定、低阅读、违规风险、活跃中或放大期 | -| 约束 | 合规类别、宣传边界、版权、隐私、图片权利、品牌语气、平台风险 | - -## 2. 起号模式判断 - -| 模式 | 适用情况 | 优先优化 | 注意事项 | -| --- | --- | --- | --- | -| IP信任号 | 用户想做长期品牌、服务、产品或读者信任 | 清晰承诺、稳定赛道、专业度、留存、转化路径 | 反馈较慢,避免追逐无关热点 | -| 流量主内容号 | 用户想测试流量收益或低成本内容题材 | 细分主题、生产系统、原创度、版权安全、复盘循环 | 波动大,不能承诺收益或账号寿命 | -| 修复号 | 账号有旧内容、低阅读或定位漂移 | 诊断、清理、垂直冲刺、信任重建 | 先判断是否有违规或错误受众信号 | -| 放大型账号 | 已经有部分有效选题 | 模式扩展、主题簇、系列化、转化 | 胜出模式没稳定前不要过度扩张 | - -默认建议优先做 IP信任系统。只有当用户明确接受风险和有限上限时,才设计流量主实验。 - -## 3. 账号地基 - -先产出定位句: - -`我帮助[目标读者],用[方法/证明/内容承诺]解决[具体痛点],让他们获得[理想结果]。` - -把定位句转成: - -- 头像:清晰、可识别、符合领域气质。 -- 名称:一个领域关键词,加一个有记忆点的身份或IP名。 -- 简介:一句话写清身份、价值和更新承诺。 -- 自动回复:身份标签、价值承诺和一个站内下一步动作。 -- 固定开场白:每篇文章可重复的一句IP记忆钩子。 -- 内容支柱:3到5个支柱,分别承接搜索、信任、故事、证明和转化。 -- 关键词宇宙:类目词、痛点词、场景词、人群词、对标邻近词。 - -### 账号准备度检查表 - -| 模块 | 检查点 | -| --- | --- | -| 主页 | 头像、名称、简介、自动回复、开场白都指向同一个读者和价值 | -| 信号 | 首轮内容保持垂直,核心关键词自然出现在文章中 | -| 素材 | 起号前至少准备10到20份源素材:案例、笔记、问题、例子、截图、故事 | -| 对标 | 已记录5到10个对标账号、20到50篇对标文章 | -| 发布 | 有文章简报、发布日历、复盘表和风险检查表 | -| 合规 | 不做虚假互动、刷量、隐藏联系方式、夸大承诺、复制图片或复制文字 | - -## 4. 对标系统 - -好对标不等于大号。优先选择: - -- 读者群相同或相邻。 -- 近期文章有明显阅读或互动信号。 -- 小号或中腰部号里出现重复爆文结构。 -- 主页承诺稳定,主题簇清晰。 -- 评论区能看到读者痛点、反对意见、真实用词和转发动机。 - -对标记录表: - -| 字段 | 记录内容 | -| --- | --- | -| 账号 | 名称、定位、赛道、读者、可见粉丝/阅读信号 | -| 文章 | 链接、标题、日期、主题、形式 | -| 钩子 | 读者为什么会点开 | -| 标题模式 | 数字、对比、热词、疑问、对话、好奇、俗语、引用、评论角度 | -| 结构 | 故事、清单、教程、观点、案例、对比、科普 | -| 情绪触发 | 好奇、身份、焦虑、松弛、愤怒、感动、自豪、实用收益 | -| 证明 | 数据、故事、截图、资质、经验、引用、来源 | -| 行动引导 | 关注、评论、收藏、转发、回复关键词、阅读系列、合规咨询 | -| 重建方向 | 如何用用户自己的素材改写成新文章 | - -对标账号的标签或合集只能作为研究线索,不承诺带来推荐流量。标签必须真实、相关、稳定。 - -## 5. 选题库 - -按文章任务分类: - -| 任务 | 目的 | 常见模式 | -| --- | --- | --- | -| 搜索 | 承接主动问题 | 怎么选、避坑、攻略、模板、遇到某问题怎么办 | -| 信任 | 证明专业度 | 案例拆解、错误复盘、方法说明、来源验证 | -| 故事 | 提高完读率 | 冲突、反转、具体场景、人物选择 | -| 情绪 | 促进转发评论 | 共同困境、身份认同、争议观点、松一口气 | -| 证明 | 降低怀疑 | 合规前后对比、截图、过程、比较表、检查清单 | -| 转化 | 推动合格读者下一步 | FAQ、服务说明、场景诊断、评论提问 | -| 留存 | 让读者记住账号 | 系列、固定开场、固定栏目、周复盘 | - -### 冷门题材起号备选 - -仅当用户想做低竞争流量主实验或小众知识号时使用: - -- 生僻字:读音、字形、字源、历史故事、现代用法、记忆口诀。 -- 易读错字:常见误读、正确读音、权威来源、使用场景。 -- 成语或老话:出处、常见误用、现代解释、故事化结尾。 -- 怀旧老物件、小众职业、小众旅行、手艺、地方知识、行业经验。 - -冷门题材必须保证原创度和事实准确。不要批量生产低质量近似文章。设置退出规则:如果内容带来低质流量、版权风险或长期账号价值弱,就停止或转向。 - -## 6. 标题模式 - -标题模式用于设计备选,不用于夸大事实。 - -| 模式 | 用法 | -| --- | --- | -| 数字法 | 让价值具体,例如“7个检查点” | -| 对比法 | 制造张力,例如“多数人做X,但真正有效的是Y” | -| 热词法 | 只在确实相关时连接当前事件或共同语境 | -| 疑问法 | 直接说出读者心里的问题 | -| 对话法 | 让标题像真实说话 | -| 好奇法 | 留一个具体的信息缺口 | -| 俗语法 | 改写熟悉表达,但避免空泛 | -| 引用法 | 只在合法且真正相关时使用 | -| 高赞评论角度 | 从真实评论重建角度,不复制原文 | - -每篇文章先写8到12个标题,再按读者意图、具体程度、可信度和风险选择。 - -## 7. 文章简报模板 - -写正文前先填这张表。 - -| 模块 | 内容 | -| --- | --- | -| 文章任务 | 搜索 / 信任 / 故事 / 情绪 / 证明 / 转化 / 留存 | -| 目标读者 | 谁最应该点开这篇 | -| 读者状态 | 痛点、好奇、误解、恐惧、渴望、反对意见 | -| 关键词 | 1个主关键词,加2到4个相关词 | -| 标题组 | 8到12个标题,并标出推荐标题 | -| 开头钩子 | 前1到3行:痛点、冲突、结果或场景 | -| 正文结构 | 3到6个小节,每节一个观点和一个证明/细节 | -| 故事节点 | 冲突 -> 原因 -> 信息 -> 情绪转折 | -| 证明材料 | 用户自有案例、截图、经验、来源或过程 | -| 风格 | 口语化、短句,必要时使用“你/我” | -| 行动引导 | 与文章任务匹配的一个站内动作 | -| 风险检查 | 宣传承诺、版权、隐私、医疗/金融/法律风险、平台规则风险 | - -### 朋友脑改写 - -草稿写完后,把它当作饭桌上讲给朋友听的一件事: - -- 把端着的表达改成能说出口的话。 -- 拆短句子。 -- 加入具体场景、例子和利害关系。 -- 删除空泛总结段。 -- 一篇文章只保留一个核心承诺。 - -## 8. 提示词模板 - -以下模板只能作为起点,必须按用户赛道、素材、证明和语气大幅定制。 - -### 通用公众号文章提示词 - -```text -你正在为[账号定位]起草一篇微信公众号文章。 - -目标读者:[读者] -文章任务:[搜索/信任/故事/情绪/证明/转化/留存] -核心痛点或好奇心:[痛点] -主关键词:[关键词] -用户自有素材:[案例、笔记、截图、故事、研究资料] -指定角度:[角度] -风险边界:不夸大承诺,不复制文字,不复制图片,不编造数据,不设计隐藏联系方式。 - -先输出原创文章简报: -1. 10个标题选项,并标出最好的3个。 -2. 开头钩子选项。 -3. 3到6节正文大纲。 -4. 故事/情绪节点。 -5. 每节需要的证明材料。 -6. 站内行动引导。 -7. 合规和原创度风险。 - -然后用中文写正文,要求口语化、短段落、例子具体。 -``` - -### 生僻字文章提示词 - -```text -为一个小众知识型微信公众号,创作一篇关于生僻汉字的原创文章。 - -请选择一个不常见、但生活中偶尔可能遇到的汉字。写作前先核验读音和释义,优先参考可靠字典或权威资料。 - -文章要求: -1. 标题包含汉字、读音悬念和知识/情绪钩子。 -2. 开头用贴近日常生活的场景。 -3. 说明正确读音和常见误读。 -4. 拆解字形。 -5. 解释本义和词义变化。 -6. 如有历史或文学引用,只使用可核验内容。 -7. 补充现代用法或相关词语。 -8. 设计记忆口诀。 -9. 用互动问题结尾。 - -全文800到1000个中文字符。语言要生动、原创,不要拼接百科或旧文章。 -``` - -### 易读错字文章提示词 - -```text -创作一篇关于易读错汉字的原创微信公众号文章。 - -汉字或词语:[汉字/词语] -目标读者:喜欢语言、文化和实用知识的人。 - -硬性要求: -1. 用真实生活中的误读场景开头。 -2. 给出正确读音和常见误读。 -3. 解释字形、来源和语义演变。 -4. 古代、近现代、当代用例必须有事实依据,不能编造典故。 -5. 设计一个记忆口诀。 -6. 用评论互动问题结尾。 - -语气要生动、接地气。写作前核验事实,避免伪造出处或复制百科腔。 -``` - -## 9. 首个30天起号日历 - -| 阶段 | 天数 | 目标 | 工作 | -| --- | --- | --- | --- | -| 地基 | 1-3 | 建立账号信号和读者承诺 | 定位句、主页、自动回复、开场白、3到5个内容支柱 | -| 对标 | 4-7 | 建立模式库 | 5到10个账号、20到50篇文章、标题/结构/评论拆解 | -| 草稿储备 | 5-10 | 避免临时赶稿 | 准备10到15篇简报或草稿 | -| 首轮冲刺 | 8-20 | 测试垂直信号 | 在一个赛道内稳定发布,避免突然跨类目 | -| 模式扩展 | 21-27 | 复用早期有效模式 | 改写有效角度,做系列,优化标题和开头 | -| 复盘 | 28-30 | 确定下轮重点 | 排名选题、标题、结构、行动引导、产能瓶颈和风险 | - -源文提到日更和早间发布,这只能作为实验假设,不是保证。可持续质量优先于强行日更。 - -## 10. 指标诊断 - -按失败环节诊断。 - -| 现象 | 可能原因 | 修复动作 | -| --- | --- | --- | -| 曝光低 | 账号信号不清、赛道漂移、可能合规风险、首轮内容弱 | 收紧主页,保持垂直,检查风险,发布更一致的内容 | -| 有曝光但打开低 | 标题/开头承诺弱、选题泛、读者不匹配 | 重写标题组,强化钩子,使用更具体的痛点或好奇点 | -| 打开后完读低 | 开头慢、语言端着、没有故事张力、抽象太多 | 做朋友脑改写,加冲突和具体例子 | -| 阅读有但收藏低 | 不够实用,缺少清单/模板/流程 | 增加可带走资产、表格、步骤或判断标准 | -| 阅读有但评论低 | 没有明确问题,没有身份或决策张力 | 问一个和读者处境有关的具体问题 | -| 互动有但关注低 | 文章有用,但账号承诺不清 | 强化简介、系列承诺、开场白和内链 | -| 关注有但转化弱 | 行动引导不清或太早销售 | 使用站内下一步、FAQ、诊断或合规咨询入口 | - -类别内相对基准比通用阈值更重要。 - -## 11. 风险规则与合规替代 - -把高风险技巧改成安全做法。 - -| 高风险诉求 | 处理方式 | -| --- | --- | -| “能不能让朋友帮忙点开/转发冲数据?” | 提醒人为互动会污染受众信号;建议私下收集质量反馈,而不是制造平台行为 | -| “能不能买阅读或刷量?” | 拒绝;转向标题、选题、完读率和复盘机制优化 | -| “怎么复制对标文章还不被发现?” | 拒绝洗稿和规避检测;只抽结构,用原创资料和自有证明重建 | -| “图片能不能直接从搜索结果拿?” | 要求使用合法授权、自有图片、可合规截图或生成/授权素材 | -| “低阅读要不要注销重来?” | 先诊断违规、定位漂移和质量问题;只有有明确战略理由时才重开 | -| “怎么隐藏外部联系方式?” | 拒绝绕审核;改用合规站内行动引导或官方商业功能 | - -建议话术: - -- “我不能帮你设计规避平台审核的方法,但可以把它改成合规的内容和行动引导系统。” -- “对标只学结构,不搬原文。我们用你的素材和证明重新写。” -- “涉及平台规则或政策敏感内容时,先核验当前微信公众号规则。” \ No newline at end of file diff --git a/crews/main/knowledge/channels-account-launch-expert/xhs.md b/crews/main/knowledge/channels-account-launch-expert/xhs.md deleted file mode 100644 index fa5f3371..00000000 --- a/crews/main/knowledge/channels-account-launch-expert/xhs.md +++ /dev/null @@ -1,309 +0,0 @@ -# 小红书起号作战手册 - -本参考手册把本地几篇小红书起号文章的方法论,整理成可复用、合规优先的操作指南。平台机制数字、趋势判断和经验阈值都只能当作经验启发,不要当成官方规则或永久有效结论。 - -## 目录 - -1. 信息收集模板 -2. 风口 / 定位 / 共鸣判断框架 -3. 账号打法:流量冲刺、个人 IP 长跑或混合打法 -4. 账号定位 -5. 起号准备清单 -6. 流量模型 -7. 人设、信任和活人感 -8. 垂直度:前期内容垂直,中期人设垂直 -9. 对标账号筛选 -10. 选题库 -11. 笔记简报模板 -12. 起号日历 -13. 数据诊断 -14. 行动优先误区清单 -15. 合规转化替代方案 - -## 1. 信息收集模板 - -当用户要起号计划、账号诊断或内容系统时,优先收集这些信息。 - -| 字段 | 需要询问或推断的内容 | -| --- | --- | -| 赛道 | 账号属于什么品类、产品或服务? | -| 可交付物 | 用户能卖什么、收集什么线索、验证什么需求? | -| 受众 | 谁痛点最强、购买能力最强、最需要被理解? | -| 证据 | 有哪些案例、结果、照片、截图、资质、故事或作品? | -| 约束 | 是否涉及敏感声明、监管行业、品牌边界或隐私风险? | -| 产能 | 每周能稳定产出多少篇,不牺牲质量? | -| 阶段 | 新号、停更号、活跃号、低流量号、违规风险号还是放大期? | - -## 2. 风口 / 定位 / 共鸣判断框架 - -在做日历之前先判断这三件事,避免两类常见错误:追一个自己无法持续的风口,或在没有发布反馈前过度打磨定位。 - -| 维度 | 核心问题 | 应产出的内容 | -| --- | --- | --- | -| 风口 | 当前是否有内容浪潮、季节需求、平台行为或社会语境正在放大? | 3 到 5 个机会角度,并标注是否需要实时核验趋势 | -| 定位 | 用户有什么兴趣、能力、证据和不可复制优势,能长期输出? | 定位句、内容支柱和“不做清单” | -| 共鸣 | 什么会让受众感觉被帮助、被看见、被理解、被鼓励? | 情绪钩子库、受众原话、评论引导问题 | - -如果来源材料说某个趋势正在火,要把它当成时间敏感信息。用户要当前起号建议时,先核验当下趋势信号,再做判断。 - -## 3. 账号打法:流量冲刺、个人 IP 长跑或混合打法 - -先明确账号打法,不要默认所有账号都追同一种增长。 - -| 打法 | 适合谁 | 优点 | 风险 | 节奏 | -| --- | --- | --- | --- | --- | -| 流量冲刺 | 喜欢追趋势、做产品验证、研究爆款形式的人 | 反馈快、涨粉峰值高、实验多 | 容易同质化,容易疲惫,信任较弱 | 高频发布,快速复盘 | -| 个人 IP 长跑 | 专家、创业者、教练、有真实经历和长期品牌目标的人 | 信任强、内容生命周期长、关系质量高 | 起号慢,需要真实证据和持续表达 | 稳定周更,长期沉淀 | -| 混合打法 | 本身能力刚好踩中风口的人 | 既接住趋势,又沉淀个人识别度 | 边界不清时容易变散 | 风口笔记 + 固定信任系列 | - -判断原则:不要只优化粉丝数。一个信任度、购买意图和反复关注都更强的小众受众,可能比大量路人粉更有价值。 - -## 4. 账号定位 - -先写定位句: - -`我帮助 [目标客户] 用 [方法/产品/证据] 解决 [具体问题],从而获得 [理想结果]。` - -把定位句翻译成: - -- 昵称:包含一个好记的人物身份或品类关键词。 -- 简介:说清价值、证据和更新承诺。不要放违规联系方式或诱导表达。 -- 内容支柱:3 到 5 个可重复栏目,分别服务搜索、信任、证明、互动和转化。 -- 关键词宇宙:品类词、问题词、产品词、场景词、竞品相邻词。 -- 行动引导风格:每篇笔记只放一个与任务匹配的平台内行动。 - -同时找出用户的“不可复制优势”。 - -| 优势类型 | 例子 | -| --- | --- | -| 人生经历 | 逆袭、转型、失败恢复、留学、育儿、搬迁、重新开始 | -| 专业证明 | 岗位、作品集、客户项目、产品经验、行业判断 | -| 审美或生活方式 | 品味、日常仪式、居住空间、旅行、视觉风格 | -| 学习边缘 | 比新手领先一步,公开记录学习过程 | -| 资源通道 | 工具、数据、访谈、幕后过程、社群问题 | - -## 5. 起号准备清单 - -把清单当作质量门槛,不要承诺“完成这些就一定被推荐”。 - -| 模块 | 检查点 | -| --- | --- | -| 主页 | 头像清楚,昵称带识别点,简介表达价值,视觉方向一致 | -| 内容方向 | 一个主赛道,起号期避免频繁跨类目漂移 | -| 素材 | 至少 20 条原始素材:图片、案例、问题、异议、过程截图、可合规展示的前后对比 | -| 合规 | 不隐藏联系方式,不夸张承诺,不做禁限品类,不虚假稀缺,不误导证明 | -| 搜索 | 标题、正文、标签和选题自然包含核心关键词 | -| 生产系统 | 有笔记简报、日历、复盘表和对标文件 | - -### 起号前准备节奏 - -新号或停更号开始正式发布前,可以先做短周期准备: - -- 浏览、收藏目标赛道内容,让推荐流逐渐贴近目标领域。 -- 自然关注和收藏对标账号。 -- 定位明确后再完善主页。 -- 发布前准备 10 到 15 篇候选笔记。 -- 以可持续节奏发布;每天 1 到 3 篇只适合产能和质量都跟得上的情况。 -- 避免刷量、互赞互粉、垃圾评论和任何操纵推荐系统的行为。 - -## 6. 流量模型 - -把流量解释成三个入口: - -- 推荐流量:受选题匹配、点击率、互动、关注意愿和账号一致性影响。 -- 搜索流量:受关键词需求、笔记相关性、账号权重和长期内容质量影响。 -- 分享流量:受实用性、情绪、稀缺感、身份认同和转发价值影响。 - -来源文章提到的 CES 式权重,只能作为心智模型: - -- 点赞和收藏代表兴趣。 -- 评论和分享代表更强参与。 -- 关注代表账号层面的信任。 - -不要把这个模型说成已核验的现行官方机制,除非当前任务里已经查过官方来源。 - -## 7. 人设、信任和活人感 - -小红书常常更像“杂志 + 真人秀”:用户不只看信息,还会判断内容背后这个人的生活、审美、故事和可信度。不要包装完美人设,要用具体真实建立信任。 - -信任可以分层搭建: - -| 层级 | 表达方式 | -| --- | --- | -| 活人感 | 露脸、声音、手写、工作台、日常场景、适度承认真实不确定 | -| 证明 | 案例、工作过程、合规前后对比、截图、样例、作品集 | -| 有用 | 清单、模板、判断标准、避坑、对比 | -| 共鸣 | “这个问题我懂”“这种感受我也经历过”的瞬间 | -| 一致性 | 固定栏目、稳定价值观、重复语言、可识别视觉节奏 | - -露脸和视频通常有助于建立信任,但不要强迫用户露脸。可提供隐私友好替代方案:手部演示、桌面场景、语音旁白、过程图、标注截图或固定视觉符号。 - -## 8. 垂直度:前期内容垂直,中期人设垂直 - -分阶段理解垂直度。 - -| 阶段 | 垂直目标 | 建议 | -| --- | --- | --- | -| 早期 | 内容垂直 | 足够窄,让平台和用户知道账号讲什么 | -| 中期 | 人设垂直 | 相邻话题可以成立,但必须延展同一个人、价值观和受众承诺 | -| 成熟期 | 信任垂直 | 用户关注的是这个人,主题可以谨慎扩展 | - -如果用户兴趣很多,建议先做一个主账号;只有在产能、边界和合规都允许时,才考虑分账号。不要建议批量矩阵、滥用账号或人为制造增长。 - -## 9. 对标账号筛选 - -好对标不是大号,而是“用户相似、体量可参考、形式能拆解”的账号。优先选择: - -- 粉丝少于 1 万,但互动明显高于粉丝体量。 -- 最近 3 个月有出圈或明显高互动笔记。 -- 主页反复出现相似封面、标题或内容结构。 -- 受众、价格带、使用场景或决策触发点相似。 -- 评论区暴露了痛点、异议、用户原话和购买意向。 - -对标记录表: - -| 字段 | 记录内容 | -| --- | --- | -| 账号 | 名称、粉丝数、定位、目标受众 | -| 笔记 | 链接/标题/日期、互动、形式 | -| 钩子 | 用户为什么会点进来 | -| 关键词 | 搜索词或场景词 | -| 封面 | 版式、承诺、视觉证明、对比 | -| 正文 | 故事、清单、对比、案例、教程、测评、观点 | -| 证明 | 图片、截图、数据、过程、身份、反馈 | -| 行动引导 | 评论、收藏、关注、提问、店铺、咨询 | -| 评论挖掘 | 用户原话、真实问题和异议 | -| 重建思路 | 如何用用户自己的证据和表达改写结构 | - -## 10. 选题库 - -按“任务”给选题分类。 - -| 任务 | 目的 | 示例模式 | -| --- | --- | --- | -| 搜索 | 承接主动需求 | “[产品/问题]怎么选”“[品类]避坑”“[场景]攻略” | -| 信任 | 证明专业度 | 案例拆解、过程公开、错误复盘 | -| 互动 | 收集评论并训练受众信号 | 提问、投票、经验分享 | -| 证明 | 降低购买犹豫 | 前后对比、客户故事、对照表、清单 | -| 转化 | 把合格用户推向下一步 | 服务说明、FAQ、真实但不过度的名额/安排 | -| 留存 | 让用户记住账号 | 系列、个人观察、幕后记录 | - -从来源文章抽象出的通用选题模式: - -- 故事意外:把产品或服务放进一个小事故、反差或“没想到”的结果里。 -- 避坑教育:把用户常见错误变成实用提醒。 -- 互动提问:问第一次购买、最大担心、预算、使用场景或决策矛盾。 -- 寓意和场景:把产品连接到身份、人生时刻、关系或具体使用。 -- 产品文案:用拟人、感官或利益点解释一个产品。 -- 日常证明:展示工作场景、包装、流程、筛选、交付或日常专业判断。 - -再按“优势来源”给选题打分: - -| 来源 | 什么时候加分 | -| --- | --- | -| 我喜欢 | 创作者能持续表达,不容易耗尽 | -| 我擅长 | 创作者有技能、经验或比新手更快的判断 | -| 我难被复制 | 角度依赖个人经历、资源、审美或证据 | -| 风口在上升 | 当前需求变大,并且匹配创作者素材 | - -如果一个选题四项都不满足,即便看起来很火,也应当降级为低优先级实验。 - -## 11. 笔记简报模板 - -每篇笔记都用这个模板。 - -| 模块 | 填写内容 | -| --- | --- | -| 笔记任务 | 搜索 / 信任 / 互动 / 证明 / 转化 / 留存 | -| 目标读者 | 谁会在刷到时停下来? | -| 关键词 | 1 个主关键词 + 2 到 4 个相关词 | -| 封面承诺 | 用户点进来的可见理由 | -| 标题 | 简短、具体,有好奇心或实用价值 | -| 开头钩子 | 前 1 到 2 行说痛点、结果、冲突或故事 | -| 正文结构 | 3 到 6 个段落,每段一个观点和一个细节/证据 | -| 证据 | 用户自己的图片、案例、截图、过程或可信经历 | -| 人味细节 | 一个真实场景、限制、错误、瞬间或观点 | -| 行动引导 | 一个与笔记任务匹配的平台内行动 | -| 标签 | 品类、产品、场景、问题和人群标签 | -| 风险检查 | 声明、版权、隐私、医疗/金融/法律风险、平台规则风险 | - -## 12. 起号日历 - -从 0 开始时,可用 30 天起号计划。 - -| 阶段 | 天数 | 目标 | 工作 | -| --- | --- | --- | --- | -| 定位 | 1-3 | 明确账号并整理素材 | 定位句、主页草稿、关键词地图、20 条素材 | -| 对标 | 4-7 | 建立模式库 | 20 到 50 条对标笔记、评论挖掘、第一版选题库 | -| 第一轮发布 | 8-14 | 测试 3 到 5 个支柱 | 发布准备好的笔记,不要过度解读早期弱数据 | -| 模式放大 | 15-21 | 重复早期有效信号 | 改写有效角度,测试封面/标题,补充搜索选题 | -| 复盘聚焦 | 22-30 | 选择下个月重点 | 排序选题、形式、行动引导、转化信号和生产瓶颈 | - -60/90 天计划可以延续周复盘循环,只放大已经出现信号的内容支柱。 - -对卡在规划期的人,使用行动优先版本: - -| 里程碑 | 学习目标 | -| --- | --- | -| 第 1 条笔记 | 打破发布阻力,熟悉发布流程 | -| 第 10 条笔记 | 看出哪些支柱最容易持续产出 | -| 第 50 条笔记 | 找到重复出现的用户问题和早期形式信号 | -| 第 100 条笔记 | 根据证据重新聚焦定位,而不是靠想象定位 | - -## 13. 数据诊断 - -按笔记失败环节诊断。 - -| 现象 | 可能问题 | 修正方向 | -| --- | --- | --- | -| 曝光低 | 账号/品类信号不清,可能有合规问题,赛道一致性弱 | 收紧定位,检查风险,发布更多垂直内容 | -| 有曝光但阅读低 | 封面/标题弱,承诺不清,视觉证明不足 | 提升封面对比、标题具体度和关键词意图 | -| 有阅读但收藏低 | 不够有用,缺少清单/流程,观点太多但工具少 | 增加步骤、判断标准、模板、对比 | -| 有阅读但评论低 | 没有问题、冲突或身份触发 | 增加具体评论问题或决策矛盾 | -| 有互动但不涨粉 | 单篇有用,但账号承诺不清 | 强化主页和系列承诺 | -| 有关注但无咨询 | 行动引导弱或服务不清 | 让下一步更平台内、更清晰、更价值优先 | - -不同品类基准差异很大,优先用用户所在赛道的基线,不要迷信通用阈值。来源文章提到的几百互动、几千互动等,只能当作粗略参考。 - -基础数据之外,再做共鸣判断: - -| 信号 | 解读 | -| --- | --- | -| 评论说“这就是我” | 情绪共鸣强,可以扩展成系列或框架 | -| 收藏多但关注少 | 单篇有用,账号承诺不够清晰 | -| 关注有增长但评论弱 | 信任可能在形成,但互动问题太封闭 | -| 多篇都弱 | 重新检查风口、承诺、证据,以及创作者是否隐藏了最有辨识度的部分 | - -## 14. 行动优先误区清单 - -当用户迟迟不发,或被早期数据打击时,用这张表。 - -| 误区 | 重新理解 | 下一步 | -| --- | --- | --- | -| 收藏很多但不发布 | 学习已经变成拖延 | 用一个已有问题发布一条小笔记 | -| 弱数据后立刻放弃 | 反馈常常有延迟 | 先完成最小样本量再判断 | -| 一开始就变现焦虑 | 信任会创造变现选项 | 先创造价值和证明,再推动交易 | -| 过度规划定位 | 定位有一部分是做出来的 | 围绕 2 到 3 个支柱先发 10 条 | -| 觉得自己不够专业 | 比新手领先一步也有价值 | 诚实分享过程、错误和正在学习的东西 | -| 过度研究爆款 | 结构可学,身份不能照搬 | 加入个人证据、故事、审美或观点 | -| 怕被熟人看见 | 多数人并没有那么关注你 | 选择隐私友好的形式,然后发布 | -| 把粉丝数当唯一目标 | 信任才是长期资产 | 追踪收藏、评论、重复问题和咨询 | - -## 15. 合规转化替代方案 - -当来源材料或用户要求隐藏联系方式、用谐音绕检测、规避审核或把违规风险转移给小号时,拒绝该操作,并替换成合规方案。 - -更安全的替代方案: - -- 让用户在平台内评论一个清楚的问题,并在平台内回答。 -- 只在符合当前平台规则时,使用允许的群聊、话题或官方互动工具。 -- 使用官方店铺、服务页、企业号能力或平台批准的线索工具。 -- 提供清单、对照表或咨询说明,但不隐藏站外联系方式。 -- 只有在当前规则允许时,才在主页放透明品牌信息。 -- 用连续内容、置顶 FAQ 和可见证明建立信任,不强行跳转私域。 - -推荐话术: - -- “我不能帮你设计绕过平台审核的方法,但可以帮你改成合规的平台内行动引导。” -- “我们把下一步做成平台内动作:评论你的情况、收藏清单,或使用账号已有的官方入口。” -- “使用这条转化路径前,先核验当前小红书社区规则和企业号规则。” \ No newline at end of file diff --git a/crews/main/scripts/crop_watermarks.py b/crews/main/scripts/crop_watermarks.py index 363be09c..1c37f5bb 100644 --- a/crews/main/scripts/crop_watermarks.py +++ b/crews/main/scripts/crop_watermarks.py @@ -2,7 +2,7 @@ """ 批量裁剪图片底部水印(知乎等平台右下角账号水印) 用法: python3 crop_watermarks.py <图片目录> -示例: python3 crop_watermarks.py ./output_articles/xxx/images +示例: python3 crop_watermarks.py ./wx_mp/outputs/xxx/images """ from PIL import Image import os diff --git a/crews/main/skills/_shared/runtime_root.py b/crews/main/skills/_shared/runtime_root.py index 44177515..a058efbe 100644 --- a/crews/main/skills/_shared/runtime_root.py +++ b/crews/main/skills/_shared/runtime_root.py @@ -2,8 +2,8 @@ 背景:部署到 ``~/.openclaw/workspace-/skills/`` 的 skill 是指向源仓 ``~/wiseflow/crews//skills/`` 的 symlink。脚本的 ``Path(__file__).resolve()`` -会跟随 symlink 跳进源仓,导致 ROOT 指向源仓而非运行时工作区——DB / prediction.md -等运行时数据都在工作区下,源仓里没有,于是全部 ``exists()=False``。 +会跟随 symlink 跳进源仓,导致 ROOT 指向源仓而非运行时工作区——DB / dna-meta.json / +DNA 评估报告等运行时数据都在工作区下,源仓里没有,于是全部 ``exists()=False``。 本 helper 按 marker 文件是否存在判定,把 ROOT 映射回 ``~/.openclaw/workspace-``。 """ diff --git a/crews/main/skills/content-calibrator/SKILL.md b/crews/main/skills/content-calibrator/SKILL.md index dd5760ae..1f83bd1f 100644 --- a/crews/main/skills/content-calibrator/SKILL.md +++ b/crews/main/skills/content-calibrator/SKILL.md @@ -1,6 +1,6 @@ --- name: content-calibrator -description: 内容校准预测循环——打分+盲预测合一 → 发布 → 记录 → T+3d 复盘 → 进化 rubric。本技能负责打分+预测(blind sub-agent + score-only.sh + commit-prediction.sh + 阈值门)与校准闭环;发布记录与数据采集由 published-track 统一管理。 +description: DNA 表现评估引擎——消费 published-track 的发布与互动数据,按量触发(每平台每 DNA 累积 ≥5 条成熟记录)评估 DNA 好坏,趋势优先(账号基线归一化 + 走向判定),产出评估报告与优化建议,经用户确认后回写 DNA。平台特有的归因方法由各平台专家包 review workflow 提供。发布记录与数据采集由 published-track 统一管理。 metadata: openclaw: emoji: 🎯 @@ -8,480 +8,149 @@ metadata: bins: - bash - sqlite3 - - node + - python3 --- -# Content Calibrator — 内容校准预测循环 +# Content Calibrator — DNA 表现评估引擎 -> **三条不可妥协原则**: -> 1. **盲预测**:预测必须在看到实际数据之前写完,写完即 immutable -> 2. **升级需盲重打验证**:新公式必须盲重打 10 篇 + `validate-rubric.sh` 降幅达标才落地,Agent 不得自动升级 -> 3. **rubric 是工作台不是博物馆**:被推翻/吸收的观察删掉,git history 是档案 +> **四条评估铁律**: +> 1. **趋势优先**:绝对值只作上下文。数据好坏看「同账号相对值 + 其走向」——新号数据天然低、老号天然高,同 DNA 跨账号不可直接比绝对值,要的是趋势 +> 2. **脚本只给证据**:聚合、基线、比值、走向由 `content-calibrator eval` 计算;好坏归因由 Agent 回读 DNA 文档与作品原文完成,脚本不下结论 +> 3. **先排混杂再归因**:账号成熟度、粉丝自然增长、选题热度、季节性流量都会污染信号;排除不了的混杂,结论降级写「观察」不写「结论」 +> 4. **DNA 更新必须用户逐条确认**:评估只产出建议,Agent 不得自动改 DNA --- -## 核心设计:per-work 归集 + 统一 rubric +## 职责边界(与 review / published-track 的分工) -**一个作品 = 一个打分 + 一个预测 + 一个复盘。** 作品的内在内容质量与发布平台无关,故打分/预测/复盘按作品归集,rubric 全平台统一,**放行阈值也全局统一**(质量门是作品本身的事,不分平台)。平台差异(baseline 量级、受众、对标账号)仅作为**预测的输入数据**按平台保留。 - -| 组件 | 归集方式 | 位置 | +| 角色 | 回答什么 | 产出 | |------|---------|------| -| rubric 公式 | **统一** | `calibration/rubric_notes.md` | -| rubric 观察 memo | **统一** | `calibration/rubric-memo.md` | -| rubric 循环状态(mode/samples/rubric_version/**threshold**) | **统一** | `calibration/.cheat-state.json` | -| 打分(7 维 + composite) | **per-work** | `/calibration/score.json` | -| 预测 | **per-work** | `/calibration/prediction.md` | -| 复盘(含多平台分析) | **per-work** | `/calibration/retro.md` | -| baseline / audience / benchmark | **per-platform** | `calibration//.platform-state.json` + `audience.md` + `benchmark.md` | -| 发布记录 + 互动指标 | **per-platform** | published-track DB(`pub_` 表) | +| `published-track` | 数据在哪 | 发布记录 + 互动指标(每日采集入库) | +| **本技能** | **这个 DNA 好不好、该怎么改**(共性引擎) | 触发规则、基线归一化与趋势证据、评估报告结构;`/dna//evals/*.eval.md` | +| 各平台专家包 `review` workflow | 该平台的数据复盘怎么做(平台特性) | 平台归因方法(指标语义、映射、混杂因素)+ 复盘编排;heartbeat 与用户临时发起的该平台复盘都走它 | -> `` 即作品目录:文章为 `output_articles//`,视频为 `output_videos//`。 +本技能只给**共性归因步骤与原则**,不含任何平台特有归因方法,平台归因方法一律由该平台专家包 review workflow 提供。本技能的结论**必须落到 DNA 动作**(保持 / 调整某维度规则 / 调整 template 某部分),经用户逐条确认后走对应平台的 style-dna workflow 更新。 --- ## 核心闭环 ``` -📊 打分+盲预测 → 🚀 发布 → 📝 记录(1B) → 📈 T+3d 复盘 → 🧬 进化 rubric - │ - ├─ 3a: 单篇复盘批量(retro.md + rubric-memo.md) - └─ 3b: 综合评估(detect-bump-signals.sh + 混杂因素 + 建议) +📥 每日数据采集(published-track) → 📊 阈值检查(--check) → 🧬 DNA 评估(聚合+归因) → 📝 评估报告 → 👤 用户逐条确认 → 🔁 style-dna 更新 DNA ``` -打分与盲预测**同一次出分**内完成(合并理由:已读稿件、已出分值,顺手出预测; -复盘拆两步:先批量写完所有单篇 retro.md + 观察进 rubric-memo.md(3a),再做跨作品综合评估(3b)——综合评估需本批全部观察落盘后才有效。 - -### 派发策略:blind sub-agent 是条件派发,不是强制 - -blind sub-agent 的隔离价值在于"主对话已看过用户对话/实绩/复盘历史,inline 打分会污染"。**这一前提只在交互式会话成立。** 发布常走定时任务 + isolatedSession,此时主 agent 本就是全新对话、无上下文,再套一层隔离 subagent 买不到隔离收益,反而 subagent 经 `sessions_yield` 回包会让 isolated 主 agent 误判本轮结束、截断后续发布动作(见 gotcha:心跳 isolated session 交互死锁)。 - -| 会话场景 | 打分方式 | 理由 | -|---------|---------|------| -| **交互式会话**(主 agent 有对话/复盘上下文) | `sessions_spawn` blind sub-agent | 主对话被污染,需 spawn 硬隔离 | -| **定时 / isolatedSession**(发布 cron、heartbeat 触发) | **主 agent inline 打分**,不派 subagent | 全新对话无上下文,盲条件由"发布前=无实际数据"天然满足;避免 sessions_yield 截断发布流 | - -**inline 打分时必须遵守的隔离纪律**(用文字约束替代 spawn 硬隔离,弱一档但可接受):打分前**只读**稿件 + `calibration/rubric_notes.md`;**不要读** `rubric-memo.md`、`.cheat-state.json`、其他 work 的 `calibration/`、`audience.md`、`benchmark.md`。读完即出分 + 预测,不翻历史。 - ---- - -## 与 published-track 的集成 +### 触发机制:数据采集每天跑,评估按量触发 -发布流程为 **打分+预测(1A) → 发布 → 记录(1B)**。**打分+预测(1A)由本技能负责,发布记录(1B)由 published-track 负责。** +- **每日**:heartbeat 采集互动数据入库(published-track 职责,不变)。 +- **按量**:每个(平台, DNA)的**成熟待评估记录**累积 **≥5 条**时评估一轮。成熟 = 发布 ≥3 天(数据稳定);待评估 = `perf_evaluated=0` 且 `dna_id` 非空。一轮评估覆盖该 DNA 全部待评估记录(可多于 5 条)。 +- **手动兜底**:低频 DNA 凑不满 5 条时,用户说「复盘一下这个 DNA」→ `--force` 强制评估。 +- `dna_id=NULL`(历史补录/未归属)不参与 DNA 评估。 -### 流程 1A·打分+盲预测(发布前自检) +### 归因:共性步骤与原则 -发布前对稿件做盲打分 + 盲预测 + 阈值门,**避免自创自评**。派发方式按上方"派发策略"表,依会话场景选 inline 或 blind sub-agent。 +**原则**: -1. **出分+出预测**(inline 或 blind sub-agent 二选一,见派发策略表): - - **交互式会话**:主 agent `sessions_spawn` 一个 blind sub-agent,只喂 `script_path`(稿件/视频定稿)+ `calibration/rubric_notes.md`。sub-agent 硬禁读 `.cheat-state.json`/各 work 的 `calibration/`/`rubric-memo.md`/`audience.md`/`benchmark.md`/对话历史。 - - **定时 / isolatedSession**:主 agent 自己 inline 打分,只读稿件 + `calibration/rubric_notes.md`,不读上述禁读文件,不翻对话历史。 - - 两种方式输出同一份严格 JSON: - - 7 维分(ER/HP/SR/QL/NA/AB/PV,各 0-5)+ per-dim confidence - - **盲预测草稿**:cold-start 期(前 5 个作品)= 一句话 bet;过 cold-start = 每目标平台的 bucket + 概率分布 + 中枢 + 反事实场景 + 关键校准假设 -2. 主 agent 拿分调 `score-only.sh` 校验 + 算 composite + 判阈值门: - ```bash - ./skills/content-calibrator/scripts/score-only.sh \ - --content-path "output_articles/xxx/article.md" \ - --cal-er 3 --cal-hp 4 --cal-sr 3 --cal-ql 4 --cal-na 3 --cal-ab 4 --cal-pv 2 - ``` - 返回 JSON 含 `passed` 与 `failing_dims`。阈值取自根级 `calibration/.cheat-state.json` 的 `score_threshold`(**全局**,默认 0=不拦截),**每维需 > 阈值**才算通过。`--platform` 可选,仅用于校验该平台是否启用 calibration。 -3. 主 agent 调 `commit-prediction.sh` 把 **score + 预测**落盘到 `/calibration/`: - ```bash - ./skills/content-calibrator/scripts/commit-prediction.sh \ - --work-dir "output_articles/xxx" --platform wx_mp \ - --cal-er 3 --cal-hp 4 --cal-sr 3 --cal-ql 4 --cal-na 3 --cal-ab 4 --cal-pv 2 \ - --prediction-file /tmp/prediction-draft.md - ``` - 写 `score.json` + `prediction.md`。**同 work 重复打分直接覆盖**(用户有意见/未过阈值 → 改稿重打,新结果覆盖旧的)。 -4. **阈值门**:`passed=false` → 主 agent 据 `failing_dims` 改稿 → 按派发策略重新出分+预测 → 再判门。**最多 2 轮**,仍不达标 → 暂停发布、上报用户裁定。 -5. `passed=true` → 放行,进入发布技能。 -6. **平台未启用 calibration**(`calibration//.platform-state.json` 不存在或 `enabled=false`)→ 跳过 1A,直接发布。 +1. **归因方法平台特有,本技能不定义**:指标语义、互动路径(漏斗/完播/其他)、到 DNA 维度的映射、平台混杂因素清单,全部由该平台专家包 review workflow 提供。本技能只做平台无关的事:触发、聚合、归一化、趋势、报告结构。 +2. **判定看相对值与走向**:绝对值只作上下文;`baseline_insufficient` 的记录只作绝对观察,不参与趋势结论。 +3. **先排混杂再归因**:账号成熟度、粉丝自然增长、平台流量波动、选题热度、季节性等 DNA 之外能影响数据的因素,排除不了的,结论降级写「观察」不写「结论」。 -> **视频内容**:打分+预测对象是**脚本定稿**(storyboard/口播稿),不是成片。视频技能流程 = 打分+预测(定稿) → 制作 → 发布 → 记录。成片后不再打分。 -> -> **多平台发布**:作品一次打分+预测,预测文件内含每个目标平台的 bucket/中枢(各平台 baseline 不同)。打分维度分只有一组。发布到 N 个平台 → `record.sh` 调 N 次,每次同一 `--source-folder`(指向 ``),record.sh 自动从同一份 score.json 读分。 +**共性步骤**: -### 流程 1B·发布记录(由 published-track 承接) - -打分通过并发布成功后,由 `published-track/scripts/record.sh` 落库。**record.sh 直接从 `/calibration/score.json` 读分**(不再传 `--cal-*` 入参):默认要求 score.json + prediction.md 齐全否则报错(拦截漏跑 1A);`--no-cal` 显式跳过(补发/不打分)。详见 `published-track/SKILL.md`。 - -### 平台打分开关 + 全局阈值 - -`content-calibrator/scripts/cal-toggle.sh`: -- 平台开关:`--platform

--enable/--disable/--status`(per-platform) -- 全局阈值:`--threshold`(查看)/ `--set-threshold N`(设置,每维 0-5,需 >N 才放行;0=不拦截)/ `--list`(总览) - -### 数据采集由 published-track 统一管理 - -**content-calibrator 不直接抓取平台数据**, 详见 `published-track`。 - ---- - -## 路由表(触发词 → 操作) - -| 用户说 | 操作 | 前置条件 | -|--------|------|----------| -| "初始化校准 [--platform xxx]" | Init | 首次使用 | -| "打分这篇 [path] --platform xxx" / "打分+预测" | Score+Predict | rubric_notes.md 存在 | -| "复盘 [work] --platform xxx" / "T+3d 数据来了" | Retro (Step 3a 单篇 + 3b 综合评估) | 有预测 + 已发布 + 过时间窗口 | -| "升级公式" / "bump rubric" | Rubric 升级(用户发起) | 综合评估已完成 + 用户确认 | -| "导入对标 --platform xxx" / "learn from" | LearnFrom | 有 viral-chaser 报告或用户提供对标数据 | -| "校准状态 [--platform xxx]" / "calibration status" | Status | 任意时刻 | -| "加维度 XX" | 维度变更 | **必须用户确认** | -| "改权重 XX" | 权重变更 | **必须用户确认** | - -> Predict 不再是独立路由项——它已合并进"打分"。如需单独重跑预测,用"打分这篇"即可(会覆盖 `prediction.md`)。 - -### 平台启用控制 - -**是否启用某个平台的 calibration,必须由用户决定。** Agent 不得自动启用。 - -- 启用:`./skills/content-calibrator/scripts/cal-toggle.sh --platform --enable` -- 停用:`./skills/content-calibrator/scripts/cal-toggle.sh --platform --disable` -- 查看状态:`./skills/content-calibrator/scripts/cal-toggle.sh --list` - -Agent 在复盘或发布时,发现对应平台未启用 calibration,**不得自动启用**,应告知用户"该平台未启用 content-calibrator,如需启用请确认"。 - -`--platform` 为必填参数(Init 除外)。支持的平台 ID: - -| 平台 ID | 平台 | 内容形态 | -|---------|------|---------| -| `wx_mp` | 微信公众号 | 长文 | -| `wx_channel` | 微信视频号 | 短视频 | -| `xhs` | 小红书 | 图文/视频笔记 | -| `zhihu` | 知乎 | 文章/回答 | -| `bilibili` | B站 | 视频 | -| `douyin` | 抖音 | 短视频 | -| `kuaishou` | 快手 | 短视频 | -| `toutiao` | 今日头条 | 文章 | -| `youtube` | YouTube | 视频 | - ---- - -## 文件结构 - -``` -/ -├── calibration/ # 校准系统根目录 -│ ├── rubric_notes.md # 统一评分公式(blind sub-agent 可读) -│ ├── rubric-memo.md # 统一观察记录(blind 不可读) -│ ├── .cheat-state.json # 统一 rubric 循环状态(mode/samples/bump/score_threshold) -│ ├── wx_mp/ # 平台专属*数据*(无 rubric、无 predictions、无 threshold) -│ │ ├── .platform-state.json # baseline / enabled / content_form -│ │ ├── audience.md # 受众画像 -│ │ └── benchmark.md # 对标账号 -│ └── xhs/ ... -├── output_articles// -│ └── calibration/ -│ ├── score.json # 7 维 + composite + rubric_version + 时间戳(重打覆盖) -│ ├── prediction.md # 盲预测(发布前重打覆盖;发布后 immutable) -│ └── retro.md # T+3d 写一次,多平台分析内含(immutable) -└── output_videos// - └── calibration/ # 同上 -``` +1. 读 `content-calibrator eval` 聚合 JSON:每篇绝对值 + 同账号比值 + 每指标趋势走向。 +2. 对照 `/calibration/platform-state.json`、`audience.md` 了解账号阶段与受众背景。 +3. 按该平台 review workflow 的归因方法,把趋势变化落到 DNA 的具体部分与维度(需回读 `{dna-id}.dna.md` / `{dna-id}.template.md` 与待评估作品原文,`source_folder` 可定位)。 +4. 混杂排不掉 → 写「观察」;能归因 → 写「结论 + 建议」,每条建议注明改哪个维度 / template 部分。 --- -## Init — 初始化 - -为指定平台创建 `calibration//` 目录和平台数据文件。**首次初始化时同时创建根级统一 rubric(若不存在)。** - -**两种触发方式**: -- **用户主动**:用户说"初始化校准"或"我要做 XX 平台" → 交互式问答 -- **Agent 不得自主初始化**:必须用户明确要求 - -### 用户主动触发流程 +## 评估产物 -1. 询问或从 `--platform` 参数获取平台 ID -2. 若 `calibration/rubric_notes.md` 不存在 → 创建根级统一 rubric(v0)+ `.cheat-state.json`(cold-start)+ `rubric-memo.md` -3. 创建 `calibration//` + `.platform-state.json` + `audience.md` + `benchmark.md` -4. 询问用户:内容形态、典型篇幅、发布频率、对标账号(可选)、该平台 baseline -5. 如有对标账号 → 触发 LearnFrom +评估报告写入 Workspace: -```bash -./skills/content-calibrator/scripts/init.sh --platform +```text +/dna//evals/{YYYY-MM-DD}.eval.md ``` -幂等——已存在则跳过。 - ---- - -## Score+Predict — 打分+盲预测(合并) - -给单篇稿子打 rubric 分 + 出盲预测,在发布前作为自检门(流程见上方"流程 1A")。**脚本不做 LLM 打分/预测**;打分+预测由主 agent 出(inline 或 blind sub-agent,按上方"派发策略"表选),脚本只做算术、门禁、落盘。 - -**隔离规则**(无论 inline 还是 subagent,白名单/禁读清单相同;区别只是 inline 靠文字自律、subagent 靠 spawn 硬隔离): - -- **白名单只读**:稿件(`script.md`/`article.md`/`post.md`)+ `calibration/rubric_notes.md` -- **rubric 路径**:统一 rubric 只在根级 `calibration/rubric_notes.md`。`calibration//` 下**没有独立 rubric**,只有 `audience.md`/`benchmark.md`/`.platform-state.json`(平台目录里的 `rubric_notes.md` 是指向根级的软链,读它等于读根级)。派发 subagent 时应把根级 rubric 路径或内容显式喂给 subagent,不要让 subagent 自己去平台目录找。 -- **硬禁读**:`rubric-memo.md`、`.cheat-state.json`、各 `/calibration/`、`audience.md`、`benchmark.md`、对话历史 -- **输出**:严格 JSON = 7 维分(各 0-5)+ per-dim confidence + 盲预测草稿 -- 校准池重打分(Rubric 升级)**强制** blind sub-agent,不接受 inline fallback——升级是交互式深度操作,主 agent 必有上下文,且盲重打需要严格隔离保证验证有效 - -### 盲预测的"盲"与落盘分工 - -- **blind subagent 产盲预测本体**(bucket/probability/counterfactual/assumptions,或 cold-start 一句话 bet)——它没看 actuals/history/audience,预测是真正的"事前赌" -- **主 agent/脚本在落盘时追加锚点注释**(找历史相近 composite 的实绩作参考)——这是派生注释,不污染盲预测本体 -- 落盘后 `prediction.md` 的预测段 immutable(发布后不得覆盖;发布前重打可覆盖) - -### Cold-start 简化 - -前 5 个作品不要求完整 bucket 数字,只给 7 维分 + 一句话 bet。第 5 个作品复盘后解锁完整预测。计数在 `calibration/.cheat-state.json` 的 `calibration_samples`(全局)。 +报告结构: -### 当前默认 rubric(v0) +1. **整体判定**:这个 DNA 近期表现(趋势语言:改善 / 平稳 / 下滑),置信度与样本量。 +2. **趋势表**:每指标的比值走势(引 `content-calibrator eval` 输出,不重算)。 +3. **template 归因**:哪个 template 部分在起作用 / 拖后腿,证据是哪几篇(归因口径按平台 review workflow 的方法)。 +4. **逐条优化建议**:每条 = 建议内容 + 目标维度/template 部分 + 证据篇目。供用户逐条确认。 +5. **观察区**:归因不了但值得记录的信号(含混杂说明)。 -7 个维度,每维 0-5 整数分: - -| 维度 | 代号 | 含义 | 权重 | -|------|------|------|------| -| 情感共鸣 | ER | 读者能否产生"说的就是我"的代入感 | ×1.5 | -| 钩子强度 | HP | 标题/开头是否锁定注意力 | ×1.5 | -| 社会议题共振 | SR | 是否触及社会讨论 | ×1.5 | -| 金句密度 | QL | 是否有独立可传播的表达 | ×1.0 | -| 叙事性 | NA | 是否有清晰的故事弧线 | ×1.0 | -| 受众广度 | AB | 话题的普适程度 | ×1.0 | -| 实用价值 | PV | 读者能否获得可操作的信息 | ×1.0 | - -**composite = (ER×1.5 + HP×1.5 + SR×1.5 + QL + NA + AB + PV) / 8.5 × 2.0** +写完报告后必须调 `--mark-evaluated` 标记覆盖的记录,防止下轮重复评估。 --- -## Retro — 复盘 - -复盘分两步:**先批量做完所有单篇复盘,再一次性检测 bump 信号**。不在单篇复盘中途插 bump 检测——bump 是跨作品统计判断,需要本批全部观察落盘后才有效。 - -### 两个入口 - -#### 入口 1:凌晨 HEARTBEAT 自动复盘 - -心跳巡检时: -- 调 `query-retro-pending.sh` 一键拿待复盘作品列表 + 互动数据 -- 按 Step 3a → Step 3b 顺序执行(见下方流程) - -#### 入口 2:用户导入对标 - -用户主动提供对标账号/爆款内容数据,触发 LearnFrom。这是**校准 rubric 本身**的入口——通过分析对标内容,提炼高流量内容的 pattern,调整 rubric 维度和权重。 - -> **复盘的本质**:复盘是"拿实际数据验证预测,提炼观察,可能触发 rubric 升级"。导入对标是"从外部信号校准 rubric 的初始假设"。两者互补:复盘是内源校准,对标是外源校准。 - -### Step 3a·单篇复盘(批量) - -对 `query-retro-pending.sh` 返回的每个待复盘作品,**依次**执行: - -1. 读 `prediction_path` 拿盲预测(路径已在 JSON 里,无需自己拼) -2. 对比预测 vs `platforms` 里各平台的实际 `metrics`(数据已在 JSON 里,无需再查 DB) -3. 写 `/calibration/retro.md`(T+3d 写一次,immutable,含多平台实绩对比 + 假设验证/推翻) -4. 提炼本篇观察 → 追加写入**统一** `calibration/rubric-memo.md`(根级,非平台目录) -5. 更新 `calibration/.cheat-state.json` 的 `calibration_samples` - -**所有待复盘作品全部写完 retro.md + rubric-memo.md 后,才进入 Step 3b。** 不在单篇之间插 bump 检测。 +## 脚本 -### Step 3b·综合评估(一次性) +统一走顶层 wrapper `content-calibrator`(在 PATH 中,零路径拼接)。 -全部单篇复盘完成后,调脚本一次性检测偏差信号并做综合评估: +### eval — DNA 表现评估聚合引擎(唯一数据入口) ```bash -./skills/content-calibrator/scripts/detect-bump-signals.sh -``` - -**纯 DB 操作**,数据全部来自 `published_track.db`(`cal_score_*` 盲打分 + 互动指标实测),不扫文件系统。 - -脚本逻辑: -1. 查所有 `cal_enabled=1 AND cal_bump_evaluated=0` 的记录 -2. 对每条:`cal_score_*` + 互动指标 → log 桶归一化到 0-5 `actual_score`(0→0, 1-10→1, 11-50→2, 51-200→3, 201-1000→4, 1000+→5) -3. **偏差检测**(per record = per work × platform):维度分 ≥3 但 actual ≤2 = 高估;维度分 ≤2 但 actual ≥3 = 低估 -4. 偏差信号写回该记录的 `cal_bias_signals` 列,`cal_bump_evaluated` 置 1 -5. **聚合**:查所有 `cal_bias_signals IS NOT NULL` 的记录,按维度 + 方向统计 count,≥3 → bump 信号触发 -6. **触发时自动清信号**:`recommend_bump=true` 时清空 `cal_bias_signals`(信号已达阈值被消费);未触发时保留,跨轮累积直到达标 - -每条记录只处理一次(`cal_bump_evaluated` 标记)。未达阈值的信号保留在 DB 里,下一轮新记录的信号会叠加到聚合计数上,直到某维度+方向累计 ≥3 触发 bump。触发后清空,下轮从零开始。 - -返回 JSON: - -```json -{ - "newly_processed": 20, - "data_points": 112, - "signals": [ - {"dimension": "pv", "direction": "overestimate", "count": 17, "threshold": 3, "triggered": true, - "platforms": {"xhs": 13, "wx_mp": 1, "douyin": 1, "youtube": 1, "wx_channel": 1}, - "examples": [...]} - ], - "triggered_signals": [...], - "recommend_bump": true -} -``` - -`platforms` 字段给出该信号的各平台分布,供 Agent 一眼判断混杂因素。 - -Agent 拿到后: -- `recommend_bump=false` → 本轮无系统性偏差,复盘结束 -- `recommend_bump=true` → **混杂因素评估**(Agent 判断,脚本不代劳):检查 `triggered_signals` 的 `platforms` 分布 + `examples` 的 work 分布: - - **同账号混杂**:全部样本来自同一新号 → 可能冷启动惩罚,非 rubric 问题 - - **同平台混杂**:偏差集中在单一平台(如 13/17 来自 xhs)→ 可能该平台 baseline 偏移 - - **跨平台一致**:多平台多账号同向偏差 → rubric 维度失准证据强 - → 评估结论写入 `calibration/rubric-memo.md` → 在 Heartbeat 汇总中告知用户评估结论 + 建议(是否升级 rubric / 是否调整发布阈值)。**Agent 不得自动升级 rubric 或改阈值。** - -用户不确认 → 流程到此结束,新作品继续积累新信号,同样模式再现会再触发评估。 - -#### 数据来源(全部从 published-track DB) - -复盘时**只从 published-track DB 读取数据**,不另行抓取。`query-retro-pending.sh` 已把待复盘作品的互动数据带出,Agent 无需再查 DB 或 ls 目录。 - ---- - -## Bump — Rubric 升级(用户发起) - -`rubric_notes.md` 只能由用户发起修改,Agent 只能建议。**前置**:用户在看到 Step 3b 综合评估结论后明确同意升级。 - -1. **生成新公式**:Agent 综合读 `rubric-memo.md` 积累的历史观察 + 评估结论,写出新打分公式(新 `rubric_notes.md` 草稿) -2. **批量盲重打**:Agent 派 blind sub-agent 对**最新发布且有数据的 10 篇**作品用新公式重打分(不足 10 篇则用全部,最少 3 篇才做验证) -3. **脚本验证**: - ```bash - ./skills/content-calibrator/scripts/validate-rubric.sh --new-scores /tmp/new-scores.json - ``` - 脚本复用 `detect-bump-signals` 的归一化 + 偏差检测逻辑,对这 10 篇的新分数 vs 实际数据算偏差信号。**降幅 = (旧信号数 - 新信号数) / 旧信号数 ≥ 30%(默认阈值)** → `pass=true`;否则 → `pass=false`。`--reduction-threshold` 可调阈值。旧信号数=0 时直接 fail(旧公式本无偏差,无升级必要)。 -4. **pass=false** → 回到第 1 步重新生成公式,**最多 3 轮**。3 轮仍 false → 报用户"自动验证未通过,建议人工介入" -5. **pass=true → 落地**: - - 正式写入 `calibration/rubric_notes.md` - - 10 篇重打作品的新分数写回 DB(`cal_score_*` + `cal_rubric_version`) - - 重打作品的 `cal_bump_evaluated` 重置为 0(新公式下应重新参与未来 bump 检测) - - 归档 `calibration/rubric-memo.md` → `rubric-memo-v<旧版本>-<日期>.md`,按模板重置 `rubric-memo.md` - - 更新 `.cheat-state.json` 的 `rubric_version` + `last_bump_at` - -> 不做全量重打——老作品保留旧分数(历史数据),新作品按新公式打分。验证只用最新 10 篇,够代表性且成本低。 - ---- - -## 维度与权重变更规则 - -**维度和权重可以被修改,但必须满足以下条件之一**: -1. **用户主动要求** — "加个 XX 维度" / "把 SR 权重调到 2.0" -2. **Agent 提议 + 用户确认** — Agent 在综合评估中检测到系统性偏差后提议变更,**必须等待用户明确同意才生效** - -变更流程: -- 变更维度(增/删/替换)或权重 → 走 Rubric 升级流程(生成新公式 → 盲重打 → validate-rubric.sh 验证) -- 变更被拒绝 → rubric 不动,观察记入 `rubric-memo.md` - ---- - -## LearnFrom — 导入对标 - -从对标账号/爆款内容中提取 pattern,作为 rubric 初始校准信号。对标数据按平台存 `calibration//benchmark.md`,提炼的 rubric 信号进统一 `rubric-memo.md`。 - -### 数据来源 - -1. **viral-chaser 追爆报告**:已下载的爆款视频分析 → 提取结构 pattern -2. **用户提供的数据**:手动粘贴对标账号数据 -3. **published-track DB 中的历史数据**:该平台已发布内容的互动数据 - -### 流程 - -1. 确认对标来源(viral-chaser 报告 / 用户提供数据 / 历史数据) -2. 分析 pattern:哪些维度在高流量内容中一致偏高/偏低 -3. 派生 rubric 信号(调整权重/维度) -4. 写入 `calibration//benchmark.md` + 更新统一 `rubric-memo.md` - ---- +# 廉价阈值检查(heartbeat 每日跑,无触发不消耗后续 token) +content-calibrator eval --platform --check -## Status — 校准状态看板 +# 聚合触发 DNA 的证据(绝对值 + 账号基线比值 + 趋势走向) +content-calibrator eval --platform -显示校准循环状态: +# 手动触发低频 DNA(不达阈值也评估) +content-calibrator eval --platform --dna-id --force +# 评估报告写完后标记(防重复评估) +content-calibrator eval --platform --mark-evaluated --ids 3,4,5 ``` -📊 Content Calibrator 状态 -【全局 rubric】 -Rubric: v0(统一) -模式: cold-start -校准池: 0 个作品 -待复盘: 0 个作品 +参数:`--min-samples 5`(触发阈值)、`--mature-days 3`(成熟窗口)、`--baseline-window 10`(账号基线取此前最多 N 篇均值)。须从 Workspace 根调用。 -【全局阈值】每维需 >0 才放行(cal-toggle.sh --set-threshold N 修改) +基线语义:同账号**此前**(发布日更早)最多 N 篇的指标均值,避免后视;此前 <3 篇 → `baseline_insufficient`。`account` 为空的记录归入未知账号组,不与其他账号混算。 -【平台】 -wx_mp ✅ 已启用 baseline: 未定 -xhs ✅ 已启用 baseline: 未定 - -【最近复盘】 -(暂无) -``` - ---- - -## 脚本 - -### 打分结果校验(不写入数据库) - -Agent 按 rubric 打完 7 维分后,用 `score-only.sh` 校验分数合法性、计算 composite 并输出结构化 JSON,不写入 DB。此脚本不做 LLM 打分,仅校验并格式化。 +### query-metrics — 单篇指标查询 ```bash -./skills/content-calibrator/scripts/score-only.sh \ - --platform wx_mp \ - --content-path "output_articles/xxx/article.md" \ - --cal-er 3 --cal-hp 4 --cal-sr 4 --cal-ql 3 --cal-na 2 --cal-ab 4 --cal-pv 3 +content-calibrator query-metrics --platform --source-folder ``` -### 落盘打分+预测到 work 目录 - -blind subagent 出分 + 预测草稿后,主 agent 调 `commit-prediction.sh` 落盘。**同 work 重复调用直接覆盖** `score.json` + `prediction.md`。 +### init — 平台初始化 ```bash -./skills/content-calibrator/scripts/commit-prediction.sh \ - --work-dir "output_articles/xxx" --platform wx_mp \ - --cal-er 3 --cal-hp 4 --cal-sr 4 --cal-ql 3 --cal-na 2 --cal-ab 4 --cal-pv 3 \ - --prediction-file /tmp/prediction-draft.md +content-calibrator init --platform ``` -### 平台打分开关管理 +幂等。创建 `/calibration/`,默认只含 `platform-state.json`(baseline 兜底参考)+ `audience.md`;存量旧点号文件 `.platform-state.json` 自动改名。对标记录(如 `benchmark.md`)不在默认初始化内,由 `account-benchmark` workflow 按需生成。 -```bash -./skills/content-calibrator/scripts/cal-toggle.sh --list -./skills/content-calibrator/scripts/cal-toggle.sh --platform wx_mp --enable -./skills/content-calibrator/scripts/cal-toggle.sh --platform wx_mp --disable -``` +--- -### 初始化平台 +## Heartbeat 集成(凌晨任务) -```bash -./skills/content-calibrator/scripts/init.sh --platform -``` +1. 数据采集:按 published-track 流程全量更新(每日必跑)。**评估前必须保证数据新鲜**——各平台取数手段由 published-track 的 `fetch-metrics` 或平台专家包的取数工具提供,本技能只消费 DB,不直接取数。 +2. 阈值检查:各启用平台跑 `content-calibrator eval --platform --check`;全部 `triggered=false` → 本轮评估结束。 +3. 有触发 → **该平台的复盘走其专家包 review workflow**(如 wx_mp → expert-wx-mp 的 Review Workflow):取数刷新、聚合、平台归因、写 `evals/{date}.eval.md`、标记,都在 review workflow 内完成。无专家包 review workflow 的平台按上方共性步骤执行,归因只到「观察」级。 +4. 汇总:上报本轮评估的 DNA、整体判定与待用户确认的优化建议。**Agent 不得自动更新 DNA。** -幂等——已存在则跳过。首次调用同时创建根级统一 rubric。 +派发纪律:heartbeat isolated 会话中全部流程须**主 agent inline 执行**,不 spawn subagent、不 sessions_yield(评估需连续回读多文件,隔离会话无上下文污染问题)。 -### 查询 published-track 数据 +--- -```bash -./skills/content-calibrator/scripts/query-metrics.sh --platform --source-folder -``` +## Status — 状态看板 -### 构建校准池 +向用户报告校准状态时展示: -```bash -./skills/content-calibrator/scripts/build-calibration-pool.sh ``` +🧬 Content Calibrator 状态(DNA 表现评估) -从 published-track DB + 各 work 的 `calibration/score.json` 构建全局校准池(per-work 归集)。 - -### Bump 信号检测 - -```bash -./skills/content-calibrator/scripts/detect-bump-signals.sh # 默认阈值 3 -./skills/content-calibrator/scripts/detect-bump-signals.sh --threshold 5 # 改阈值 +【平台】:baseline 未定 / 已定 +【待评估】:3/5 条成熟记录(未触发) +【最近评估】/dna//evals/.eval.md — 判定:改善 +【待确认建议】2 条 ``` -纯 DB 操作:查 `cal_enabled=1 AND cal_bump_evaluated=0` 的记录 → 从 `cal_score_*` + 互动指标算偏差信号 → 写回 `cal_bias_signals` + `cal_bump_evaluated=1` → 聚合全量信号按维度统计同向偏差 → **触发 bump 时自动清空 `cal_bias_signals`**(未触发时保留,跨轮累积)。≥3 次同向 → bump 信号。返回结构化 JSON(含每信号的 platform 分布)。 +数据来源:`content-calibrator eval --check` + `/dna/*/evals/` 目录。 -### Rubric 升级验证(Rubric 升级流程用) - -```bash -./skills/content-calibrator/scripts/validate-rubric.sh --new-scores /tmp/new-scores.json -``` - -`--new-scores` 指向 blind sub-agent 用新公式重打分的 JSON 文件(格式见脚本头注释)。脚本复用 `detect-bump-signals` 的 log 桶归一化 + 偏差检测逻辑,对同一批作品对比新旧公式的偏差信号数。**降幅 = (旧 - 新) / 旧 ≥ 30%(默认)** → `pass=true`;`--reduction-threshold` 可调。返回 JSON:`{pass, sample_size, old_signals, new_signals, reduction, reduction_ratio, reduction_threshold, reason}`。exit code:0=pass,1=fail。 +--- -### 导入追爆报告 +## 历史说明 -```bash -./skills/content-calibrator/scripts/import-viral-chaser.sh --platform -``` +本技能前身为「内容校准预测循环」(统一 rubric 打分 + 盲预测 + 阈值门 + bump 升级)。2026-08-20 起 rubric 体系废除:发布前不再打分,判断准则不再是独立进化对象,发布数据直接关联 DNA(`pub_*` 表 `dna_id` 列)做表现评估。DB 中 `cal_*` 列为历史兼容保留。 diff --git a/crews/main/skills/content-calibrator/content-calibrator.sh b/crews/main/skills/content-calibrator/content-calibrator.sh new file mode 100755 index 00000000..7496af10 --- /dev/null +++ b/crews/main/skills/content-calibrator/content-calibrator.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# content-calibrator.sh — content-calibrator 顶层 wrapper(子命令分发) +# 让 agent 用 `content-calibrator <子命令> [参数...]` 走 PATH,零路径拼接。 +# 每个子命令 exec 转发到 scripts/ 下对应脚本,不改语义。 +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +# 只解析 ~/.openclaw/bin 软链一层(bin/ -> /skills//.sh), +# 不 readlink -f 继续展开 workspace 内 skills/ 指向仓库模板的软链。 +# 子脚本用 dirname $0/../../.. 推导 workspace 根(ROOT/db/…),readlink -f 会把 +# workspace 折叠成仓库模板路径,导致 ROOT 解析到模板目录(无 db → 空结果)。 +# 保留字面 workspace 路径,ROOT 才能命中真实运行数据目录。 +if [ -L "$SELF" ]; then + _target="$(readlink "$SELF")" + case "$_target" in + /*) SELF="$_target" ;; + *) SELF="$(cd "$(dirname "$SELF")" && pwd)/$_target" ;; + esac +fi +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" + +cmd="${1:-}" +if [ $# -gt 0 ]; then shift; fi + +case "$cmd" in + eval) exec bash "$SCRIPT_DIR/scripts/dna-eval.sh" "$@" ;; + query-metrics) exec bash "$SCRIPT_DIR/scripts/query-metrics.sh" "$@" ;; + init) exec bash "$SCRIPT_DIR/scripts/init.sh" "$@" ;; + *) + cat >&2 <<'USAGE' +用法: content-calibrator <子命令> [参数...] + +子命令: + eval DNA 表现评估聚合引擎(须从 workspace 根调用) + eval --platform

--check 廉价阈值检查 + eval --platform

聚合触发 DNA 的证据 + eval --platform

--dna-id --force 手动触发(不达阈值也评估) + eval --platform

--mark-evaluated --ids 3,4,5 评估完成后标记 + query-metrics 查询单篇内容的互动指标(--platform --source-folder) + init 初始化平台校准数据目录(--platform

,幂等) +USAGE + exit 1 + ;; +esac diff --git a/crews/main/skills/content-calibrator/scripts/build-calibration-pool.sh b/crews/main/skills/content-calibrator/scripts/build-calibration-pool.sh deleted file mode 100755 index 45f65e80..00000000 --- a/crews/main/skills/content-calibrator/scripts/build-calibration-pool.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# build-calibration-pool.sh — 构建全局校准池(per-work 归集) -# 递归扫描 output_articles/**/calibration/score.json 与 output_videos/**/calibration/score.json, -# 关联 published-track DB 各平台表的互动指标,输出供复盘和 bump 使用的校准池。 -# 用法: build-calibration-pool.sh -set -euo pipefail - -WORKSPACE="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/../../.." &> /dev/null && pwd )" -DB="$WORKSPACE/db/published_track.db" - -if [[ ! -f "$DB" ]]; then - echo "❌ published-track DB 不存在: $DB" - echo " 先运行 ./skills/published-track/scripts/init-db.sh" - exit 1 -fi - -echo "📊 构建全局校准池(per-work)..." -echo "" - -# 平台 → 主指标字段映射 -declare -A METRIC_FIELD -METRIC_FIELD[wx_mp]="reads" -METRIC_FIELD[wx_channel]="plays" -METRIC_FIELD[xhs]="views" -METRIC_FIELD[zhihu]="views" -METRIC_FIELD[bilibili]="plays" -METRIC_FIELD[douyin]="plays" -METRIC_FIELD[kuaishou]="plays" -METRIC_FIELD[toutiao]="reads" -METRIC_FIELD[youtube]="views" - -count=0 -for kind in output_articles output_videos; do - while IFS= read -r score_json; do - [[ -f "$score_json" ]] || continue - # work_rel = score.json 所在 calibration/ 的父目录,相对 WORKSPACE(即 --source-folder / DB source_folder) - work_abs="$(cd "$(dirname "$score_json")/.." && pwd)" - work_rel="${work_abs#$WORKSPACE/}" - composite=$(python3 -c "import json; print(json.load(open('$score_json')).get('composite','?'))" 2>/dev/null || echo "?") - rubric=$(python3 -c "import json; print(json.load(open('$score_json')).get('rubric_version','?'))" 2>/dev/null || echo "?") - - echo "── $work_rel (composite=$composite, rubric=$rubric) ──" - for p in "${!METRIC_FIELD[@]}"; do - table="pub_$p" - metric="${METRIC_FIELD[$p]}" - texists=$(sqlite3 "$DB" "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='$table';" 2>/dev/null || echo 0) - [[ "$texists" -eq 1 ]] || continue - sqlite3 -separator "|" "$DB" \ - "SELECT publish_date, COALESCE($metric,0) FROM $table WHERE source_folder='$work_rel' AND COALESCE($metric,0)>0 ORDER BY publish_date DESC LIMIT 1;" 2>/dev/null | while IFS='|' read -r d m; do - echo " $p: $m ($d)" - done - done - count=$((count + 1)) - done < <(find "$WORKSPACE/$kind" -type f -name score.json -path '*/calibration/score.json' 2>/dev/null) -done - -echo "" -echo "---" -echo "校准池总计: $count 个作品(有 score.json)" diff --git a/crews/main/skills/content-calibrator/scripts/cal-toggle.sh b/crews/main/skills/content-calibrator/scripts/cal-toggle.sh deleted file mode 100755 index 2441d956..00000000 --- a/crews/main/skills/content-calibrator/scripts/cal-toggle.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# cal-toggle.sh — 管理平台 content-calibrator 打分开关与全局阈值 -# -# 用法: -# cal-toggle.sh --list # 查看所有平台开关 + 全局阈值 -# cal-toggle.sh --platform --enable # 启用某平台打分 -# cal-toggle.sh --platform --disable # 停用某平台打分 -# cal-toggle.sh --platform --status # 查看某平台打分状态 -# cal-toggle.sh --threshold # 查看全局打分阈值 -# cal-toggle.sh --set-threshold # 设置全局阈值(每维 0-5,需 >N 才放行发布;0=不拦截) -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -CAL_ROOT="$ROOT/calibration" -DB="$ROOT/db/published_track.db" - -ACTION="" PLATFORM="" THRESHOLD_VAL="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --platform) PLATFORM="$2"; shift 2 ;; - --enable) ACTION=enable; shift ;; - --disable) ACTION=disable; shift ;; - --status) ACTION=status; shift ;; - --list) ACTION=list; shift ;; - --threshold) ACTION=threshold; shift ;; - --set-threshold) ACTION=set_threshold; THRESHOLD_VAL="$2"; shift 2 ;; - *) echo "{\"ok\":false,\"error\":\"unknown arg: $1\"}"; exit 1 ;; - esac -done - -# 支持的平台 -VALID_PLATFORMS="wx_mp wx_channel xhs zhihu bilibili douyin kuaishou toutiao youtube juejin twitter facebook instagram tiktok pinterest threads" - -# ── 全局阈值动作(不需要 --platform)── -GLOBAL_STATE="$CAL_ROOT/.cheat-state.json" - -if [ "$ACTION" = "list" ]; then - gthr=$(python3 -c "import json; print(json.load(open('$GLOBAL_STATE')).get('score_threshold',0))" 2>/dev/null || echo 0) - echo "📊 Content-Calibrator 平台打分开关" - echo " 全局阈值:每维需 >$gthr 才放行发布(cal-toggle.sh --set-threshold N 修改)" - echo "" - for p in $VALID_PLATFORMS; do - CAL_DIR="$CAL_ROOT/$p" - if [ -f "$CAL_DIR/.platform-state.json" ]; then - echo " ✅ $p — 已启用" - else - echo " ⬜ $p — 未启用" - fi - done - exit 0 -fi - -if [ "$ACTION" = "threshold" ]; then - thr=$(python3 -c "import json; print(json.load(open('$GLOBAL_STATE')).get('score_threshold',0))" 2>/dev/null || echo 0) - echo "{\"ok\":true,\"scope\":\"global\",\"score_threshold\":$thr,\"meaning\":\"每维需 >$thr 才放行发布\"}" - exit 0 -fi - -if [ "$ACTION" = "set_threshold" ]; then - if [[ -z "$THRESHOLD_VAL" ]]; then - echo '{"ok":false,"error":"--set-threshold requires a value 0-4"}'; exit 1 - fi - if [[ "$THRESHOLD_VAL" -lt 0 || "$THRESHOLD_VAL" -gt 4 ]] 2>/dev/null; then - echo "{\"ok\":false,\"error\":\"threshold must be integer 0-4 (每维 0-5, 需 >threshold, 故 threshold 上限 4)\"}"; exit 1 - fi - python3 -c " -import json -f='$GLOBAL_STATE' -d=json.load(open(f)); d['score_threshold']=$THRESHOLD_VAL -json.dump(d,open(f,'w'),ensure_ascii=False,indent=2) -" - echo "{\"ok\":true,\"scope\":\"global\",\"score_threshold\":$THRESHOLD_VAL,\"meaning\":\"每维需 >$THRESHOLD_VAL 才放行发布\"}" - exit 0 -fi - -# ── 平台级动作(需要 --platform)── -if [ -z "$PLATFORM" ]; then - echo '{"ok":false,"error":"--platform is required (or use --list / --threshold / --set-threshold)"}' - exit 1 -fi - -if ! echo "$VALID_PLATFORMS" | grep -qw "$PLATFORM"; then - echo "{\"ok\":false,\"error\":\"unsupported platform: $PLATFORM\"}" - exit 1 -fi - -CAL_DIR="$CAL_ROOT/$PLATFORM" - -case "$ACTION" in - status) - if [ -f "$CAL_DIR/.platform-state.json" ]; then - echo "{\"ok\":true,\"platform\":\"$PLATFORM\",\"cal_enabled\":true}" - else - echo "{\"ok\":true,\"platform\":\"$PLATFORM\",\"cal_enabled\":false}" - fi - ;; - enable) - if [ -f "$CAL_DIR/.platform-state.json" ]; then - echo "{\"ok\":true,\"platform\":\"$PLATFORM\",\"action\":\"enable\",\"message\":\"already enabled\"}" - else - # 调 content-calibrator 的 init.sh - bash "$(dirname "$0")/../../content-calibrator/scripts/init.sh" --platform "$PLATFORM" - echo "{\"ok\":true,\"platform\":\"$PLATFORM\",\"action\":\"enable\",\"message\":\"calibration initialized\"}" - fi - ;; - disable) - if [ ! -d "$CAL_DIR" ]; then - echo "{\"ok\":true,\"platform\":\"$PLATFORM\",\"action\":\"disable\",\"message\":\"already disabled (dir not found)\"}" - else - echo "⚠️ 禁用 $PLATFORM 的 content-calibrator 将删除 calibration/$PLATFORM/ 目录" - echo " 平台数据(baseline/受众/对标)将删除;统一 rubric 与全局阈值保留" - echo " 确认请输入 YES: " - read -r CONFIRM - if [ "$CONFIRM" = "YES" ]; then - rm -rf "$CAL_DIR" - echo "{\"ok\":true,\"platform\":\"$PLATFORM\",\"action\":\"disable\",\"message\":\"platform directory removed\"}" - else - echo "{\"ok\":false,\"platform\":\"$PLATFORM\",\"action\":\"disable\",\"message\":\"cancelled by user\"}" - fi - fi - ;; - *) - echo '{"ok":false,"error":"action required: --enable, --disable, --status, --threshold, --set-threshold, or --list"}' - exit 1 - ;; -esac diff --git a/crews/main/skills/content-calibrator/scripts/commit-prediction.sh b/crews/main/skills/content-calibrator/scripts/commit-prediction.sh deleted file mode 100755 index 7f23c0fe..00000000 --- a/crews/main/skills/content-calibrator/scripts/commit-prediction.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -# commit-prediction.sh — 把 blind subagent 产出的 score + 预测草稿落盘到 /calibration/ -# -# 写两个文件: -# /calibration/score.json — 7 维 + composite + rubric_version + 时间戳(覆盖) -# /calibration/prediction.md — 盲预测(覆盖;发布后由 agent 保证不再覆盖) -# -# 同 work 重复调用直接覆盖(用户有意见/未过阈值 → 改稿重打)。 -# -# 用法: -# commit-prediction.sh --work-dir --platform \ -# --cal-er 3 --cal-hp 4 --cal-sr 3 --cal-ql 4 --cal-na 3 --cal-ab 4 --cal-pv 2 \ -# --prediction-file /tmp/prediction-draft.md -set -euo pipefail - -WORKSPACE="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/../../.." &> /dev/null && pwd )" -CAL_ROOT="$WORKSPACE/calibration" - -WORK_DIR="" PLATFORM="" PREDICTION_FILE="" -CAL_ER="" CAL_HP="" CAL_SR="" CAL_QL="" CAL_NA="" CAL_AB="" CAL_PV="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --work-dir) WORK_DIR="$2"; shift 2 ;; - --platform) PLATFORM="$2"; shift 2 ;; - --prediction-file) PREDICTION_FILE="$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 ;; - *) echo "{\"ok\":false,\"error\":\"unknown arg: $1\"}"; exit 1 ;; - esac -done - -if [[ -z "$WORK_DIR" || -z "$PLATFORM" ]]; then - echo '{"ok":false,"error":"--work-dir and --platform are required"}' - exit 1 -fi - -# 校验分数 -for dim in ER HP SR QL NA AB PV; do - var_name="CAL_$dim" - if [[ -z "${!var_name}" ]]; then - echo "{\"ok\":false,\"error\":\"missing --cal-$(echo $dim | tr '[:upper:]' '[:lower:]')\"}" - exit 1 - fi - val="${!var_name}" - if [[ "$val" -lt 0 || "$val" -gt 5 ]] 2>/dev/null; then - echo "{\"ok\":false,\"error\":\"cal_$dim=$val out of range (must be 0-5 integer)\"}" - exit 1 - fi -done - -# 解析 work-dir:支持相对路径(output_articles/xxx)或绝对路径 -if [[ "$WORK_DIR" = /* ]]; then - WORK_ABS="$WORK_DIR" -else - WORK_ABS="$WORKSPACE/$WORK_DIR" -fi -if [[ ! -d "$WORK_ABS" ]]; then - echo "{\"ok\":false,\"error\":\"work dir not found: $WORK_ABS\"}" - exit 1 -fi - -CAL_DIR="$WORK_ABS/calibration" -mkdir -p "$CAL_DIR" - -# rubric_version 取自根级统一 state -RUBRIC_VERSION=$(python3 -c "import json; print(json.load(open('$CAL_ROOT/.cheat-state.json')).get('rubric_version','v0'))" 2>/dev/null || echo "v0") - -er="$CAL_ER" hp="$CAL_HP" sr="$CAL_SR" ql="$CAL_QL" na="$CAL_NA" ab="$CAL_AB" pv="$CAL_PV" -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}') -") -NOW="$(date '+%Y-%m-%d %H:%M:%S')" - -# 写 score.json(覆盖) -python3 -c " -import json -d = { - 'rubric_version': '$RUBRIC_VERSION', - 'platform': '$PLATFORM', - 'scores': {'ER': $er, 'HP': $hp, 'SR': $sr, 'QL': $ql, 'NA': $na, 'AB': $ab, 'PV': $pv}, - 'composite': $COMPOSITE, - 'scored_at': '$NOW' -} -json.dump(d, open('$CAL_DIR/score.json','w'), ensure_ascii=False, indent=2) -" - -# 写 prediction.md(覆盖) -{ - echo "# Prediction — $(basename "$WORK_ABS")" - echo "" - echo "> **盲预测**:发布前在看到实际数据之前写就。发布后 immutable。" - echo "> platform: $PLATFORM · rubric: $RUBRIC_VERSION · composite: $COMPOSITE · scored_at: $NOW" - echo "" - if [[ -n "$PREDICTION_FILE" && -f "$PREDICTION_FILE" ]]; then - cat "$PREDICTION_FILE" - else - echo "(未提供 --prediction-file,仅落盘分数。请补预测草稿。)" - fi -} > "$CAL_DIR/prediction.md" - -echo "{\"ok\":true,\"action\":\"committed\",\"work\":\"$WORK_DIR\",\"platform\":\"$PLATFORM\",\"composite\":$COMPOSITE,\"rubric_version\":\"$RUBRIC_VERSION\",\"score_json\":\"$CAL_DIR/score.json\",\"prediction_md\":\"$CAL_DIR/prediction.md\"}" diff --git a/crews/main/skills/content-calibrator/scripts/detect-bump-signals.py b/crews/main/skills/content-calibrator/scripts/detect-bump-signals.py deleted file mode 100755 index a38ec3b8..00000000 --- a/crews/main/skills/content-calibrator/scripts/detect-bump-signals.py +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/env python3 -"""detect-bump-signals.py — 结构化 bump 信号检测(纯 DB) - -从 published_track.db 读取所有 cal_enabled=1 的记录,对未评估的记录 -(cal_bump_evaluated=0)计算偏差信号并写回 cal_bias_signals, -然后聚合全量信号按维度统计同向偏差。≥3 次同向 → bump 信号。 -触发 bump 时自动清空 cal_bias_signals(信号已达阈值被消费); -未触发时保留信号,跨轮累积直到达标。 - -数据全部来自 DB:cal_score_*(盲打分)+ 互动指标(实测)。 -偏差信号 = 纯数学:log 桶归一化 actual → 与 dim score 比较。 - -Usage: - python3 detect-bump-signals.py # 默认阈值 3 - python3 detect-bump-signals.py --threshold 5 # 改阈值 -""" -from __future__ import annotations - -import argparse -import json -import os -import sqlite3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared")) -from runtime_root import resolve_runtime_root # noqa: E402 - -# ── 路径 ───────────────────────────────────────────────────────────────────── - -ROOT = resolve_runtime_root(Path(__file__).resolve().parent.parent.parent.parent) -DB = ROOT / "db" / "published_track.db" - -# ── 常量 ───────────────────────────────────────────────────────────────────── - -DIMENSIONS = ["er", "hp", "sr", "ql", "na", "ab", "pv"] -DIMENSION_LABELS = { - "er": "情感共鸣", "hp": "钩子强度", "sr": "社会议题共振", - "ql": "金句密度", "na": "叙事性", "ab": "受众广度", "pv": "实用价值", -} - -# 互动指标列名白名单 -METRIC_COLUMNS = { - "reads", "likes", "comments", "shares", "favorites", - "plays", "views", "danmaku", "coins", "upvotes", - "impressions", "reach", "saves", "retweets", "replies", - "bookmarks", "reposts", -} - -# 高/低分阈值 -SCORE_HIGH = 3 # dimension score ≥3 = 高分 -SCORE_LOW = 2 # dimension score ≤2 = 低分 -ACTUAL_HIGH = 3 # actual_score ≥3 = 高表现 -ACTUAL_LOW = 2 # actual_score ≤2 = 低表现 - - -# ── 归一化 ─────────────────────────────────────────────────────────────────── - -def engagement_to_score(total: int) -> int: - """互动总量 → 0-5 log 桶。 - - 0 → 0, 1-10 → 1, 11-50 → 2, 51-200 → 3, 201-1000 → 4, 1000+ → 5 - """ - if total <= 0: - return 0 - elif total <= 10: - return 1 - elif total <= 50: - return 2 - elif total <= 200: - return 3 - elif total <= 1000: - return 4 - else: - return 5 - - -def detect_bias(dim_scores: dict[str, int], actual_score: int) -> list[dict]: - """比较维度分 vs 实际表现,返回偏差信号列表。 - - 高估:维度分 ≥3 但实际 ≤2 - 低估:维度分 ≤2 但实际 ≥3 - """ - signals = [] - for dim, score in dim_scores.items(): - if score >= SCORE_HIGH and actual_score <= ACTUAL_LOW: - signals.append({"dim": dim, "dir": "overestimate", "dim_score": score, "actual_score": actual_score}) - elif score <= SCORE_LOW and actual_score >= ACTUAL_HIGH: - signals.append({"dim": dim, "dir": "underestimate", "dim_score": score, "actual_score": actual_score}) - return signals - - -# ── DB 操作 ────────────────────────────────────────────────────────────────── - -def get_all_platform_tables(conn: sqlite3.Connection) -> list[str]: - """返回所有 pub_* 表名。""" - return [r[0] for r in conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'pub_%'" - )] - - -def get_metric_columns(conn: sqlite3.Connection, table: str) -> list[str]: - """获取该表的互动指标列名。""" - cols = [r[1] for r in conn.execute(f"PRAGMA table_info({table})")] - return sorted(METRIC_COLUMNS & set(cols)) - - -def process_new_records(conn: sqlite3.Connection) -> int: - """处理所有 cal_bump_evaluated=0 的记录:算偏差信号 → 写回 DB。 - - 返回处理的记录数。 - """ - tables = get_all_platform_tables(conn) - processed = 0 - - for table in tables: - platform = table.removeprefix("pub_") - metric_cols = get_metric_columns(conn, table) - if not metric_cols: - continue - - # 无分数的记录直接标记已评估(避免重复查询) - conn.execute( - f"UPDATE {table} SET cal_bump_evaluated = 1 " - f"WHERE cal_enabled = 1 AND cal_bump_evaluated = 0 AND cal_score_er IS NULL" - ) - - # 查未评估且有分数的记录 - col_select = ", ".join(f"COALESCE({c}, 0) AS {c}" for c in metric_cols) - rows = conn.execute( - f"""SELECT id, source_folder, cal_score_er, cal_score_hp, cal_score_sr, - cal_score_ql, cal_score_na, cal_score_ab, cal_score_pv, - cal_composite, {col_select} - FROM {table} - WHERE cal_enabled = 1 - AND cal_bump_evaluated = 0 - AND cal_score_er IS NOT NULL""" - ).fetchall() - - for row in row_to_dict(conn, table, metric_cols, rows): - dim_scores = {} - for dim in DIMENSIONS: - val = row.get(f"cal_score_{dim}") - if val is not None: - dim_scores[dim] = int(val) - if not dim_scores: - continue - - total_engagement = sum(row.get(c, 0) or 0 for c in metric_cols) - actual_score = engagement_to_score(total_engagement) - biases = detect_bias(dim_scores, actual_score) - - signals_json = json.dumps(biases, ensure_ascii=False) if biases else None - conn.execute( - f"UPDATE {table} SET cal_bias_signals = ?, cal_bump_evaluated = 1 WHERE id = ?", - (signals_json, row["id"]), - ) - processed += 1 - - conn.commit() - return processed - - -def row_to_dict(conn, table, metric_cols, rows): - """将 sqlite3.Row 列表转为 dict 列表。""" - result = [] - for row in rows: - d = dict(row) - result.append(d) - return result - - -def aggregate_signals(conn: sqlite3.Connection, threshold: int) -> dict: - """聚合所有 cal_bias_signals IS NOT NULL 的记录,按维度+方向统计。""" - tables = get_all_platform_tables(conn) - all_signals = [] # [{dim, dir, dim_score, actual_score, platform, source_folder, composite}] - - for table in tables: - platform = table.removeprefix("pub_") - rows = conn.execute( - f"""SELECT source_folder, cal_composite, cal_bias_signals - FROM {table} - WHERE cal_bias_signals IS NOT NULL""" - ).fetchall() - for row in rows: - try: - signals = json.loads(row[2]) # row[2] = cal_bias_signals - except (json.JSONDecodeError, TypeError): - continue - for s in signals: - all_signals.append({ - "dim": s["dim"], - "dir": s["dir"], - "dim_score": s["dim_score"], - "actual_score": s["actual_score"], - "platform": platform, - "source_folder": row[0], # row[0] = source_folder - "composite": row[1], # row[1] = cal_composite - }) - - if not all_signals: - return {"data_points": 0, "signals": [], "triggered_signals": [], "recommend_bump": False} - - # 聚合:按 dim + dir 统计 - signal_map: dict[str, dict] = {} - for s in all_signals: - key = f"{s['dim']}:{s['dir']}" - if key not in signal_map: - signal_map[key] = { - "dimension": s["dim"], - "dimension_label": DIMENSION_LABELS.get(s["dim"], s["dim"]), - "direction": s["dir"], - "count": 0, - "platforms": {}, - "examples": [], - } - sig = signal_map[key] - sig["count"] += 1 - - # 平台分布 - p = s["platform"] - sig["platforms"][p] = sig["platforms"].get(p, 0) + 1 - - # 例子(最多 10 个) - if len(sig["examples"]) < 10: - sig["examples"].append({ - "work": s["source_folder"], - "platform": p, - "dim_score": s["dim_score"], - "actual_score": s["actual_score"], - "composite": s["composite"], - }) - - # 标记触发 - signals = sorted(signal_map.values(), key=lambda x: x["count"], reverse=True) - triggered = [] - for sig in signals: - sig["threshold"] = threshold - sig["triggered"] = sig["count"] >= threshold - if sig["triggered"]: - triggered.append(sig) - - return { - "data_points": len(all_signals), - "signals": signals, - "triggered_signals": triggered, - "recommend_bump": len(triggered) > 0, - } - - -def clear_signals(conn: sqlite3.Connection) -> None: - """聚合后清空所有 cal_bias_signals(信号已被消费,不再重复触发)。""" - for table in get_all_platform_tables(conn): - conn.execute(f"UPDATE {table} SET cal_bias_signals = NULL WHERE cal_bias_signals IS NOT NULL") - conn.commit() - - -# ── main ───────────────────────────────────────────────────────────────────── - -def main() -> int: - parser = argparse.ArgumentParser( - prog="detect-bump-signals", - description="结构化 bump 信号检测(纯 DB)", - ) - parser.add_argument("--threshold", type=int, default=3, help="同向偏差触发阈值(默认 3)") - args = parser.parse_args() - - if not DB.exists(): - json.dump({"error": f"DB not found: {DB}"}, sys.stdout, ensure_ascii=False) - sys.stdout.write("\n") - return 1 - - conn = sqlite3.connect(str(DB)) - conn.row_factory = sqlite3.Row - try: - processed = process_new_records(conn) - result = aggregate_signals(conn, args.threshold) - result["newly_processed"] = processed - # 只有触发 bump(信号已达阈值被消费)才清空; - # 未触发时信号保留在 DB 里,跨轮累积直到达标 - if result["recommend_bump"]: - clear_signals(conn) - json.dump(result, sys.stdout, ensure_ascii=False, indent=2) - sys.stdout.write("\n") - return 0 - finally: - conn.close() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/crews/main/skills/content-calibrator/scripts/detect-bump-signals.sh b/crews/main/skills/content-calibrator/scripts/detect-bump-signals.sh deleted file mode 100755 index 931219b5..00000000 --- a/crews/main/skills/content-calibrator/scripts/detect-bump-signals.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# detect-bump-signals.sh — 结构化 bump 信号检测 wrapper -# 让 agent 走 PATH 调用,零路径拼接。 -set -euo pipefail -SELF="${BASH_SOURCE[0]}" -while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done -SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" -# 默认从 PWD 推导 ROOT(agent 从 workspace 根调用) -export PUBLISHED_TRACK_ROOT="${PUBLISHED_TRACK_ROOT:-$PWD}" -exec python3 "$SCRIPT_DIR/detect-bump-signals.py" "$@" diff --git a/crews/main/skills/content-calibrator/scripts/dna-eval.py b/crews/main/skills/content-calibrator/scripts/dna-eval.py new file mode 100644 index 00000000..6f87aad2 --- /dev/null +++ b/crews/main/skills/content-calibrator/scripts/dna-eval.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""dna-eval.py — DNA 表现评估聚合引擎(published-track 数据 → 结构化证据) + +设计原则:脚本只提供聚合证据(绝对值 + 同账号相对比值 + 趋势走向), +不做定性判断;好坏归因由 Agent 回读 DNA 文档与作品原文完成。 + +三种模式: + 1. --check 廉价阈值检查(heartbeat 每日跑):各 DNA 待评估计数与是否触发 + 2. (默认)聚合模式 对触发的 DNA 输出逐篇证据 + 每指标趋势 + 3. --mark-evaluated 评估报告写完后标记 perf_evaluated=1 + +关键语义: + - 待评估记录 = publish_date ≤ 今日-mature_days 且 perf_evaluated=0 且 dna_id 非空 + - 触发条件 = 某(平台, DNA)待评估记录 ≥ min_samples + - 基线 = 同账号「此前」最多 baseline_window 篇的指标均值(避免后视); + 此前 <3 篇 → baseline_insufficient,只给绝对观察不给比值/趋势 + - account 为空 → 归入 __unknown__ 组,不参与其他账号基线 + - 趋势 = 按发布时间序对比值做最小二乘斜率:>+0.02 up / <-0.02 down / 否则 flat; + 有效点 <3 → insufficient +""" +import argparse +import json +import os +import sqlite3 +import sys +from datetime import date, datetime, timedelta + +NON_METRIC_COLS = { + "id", "title", "content_type", "source_folder", "publish_url", "publish_date", + "distribute_status", "top_comment", "notes", "dna_id", "account", "perf_evaluated", + "created_at", "updated_at", + "cal_enabled", "cal_score_er", "cal_score_hp", "cal_score_sr", "cal_score_ql", + "cal_score_na", "cal_score_ab", "cal_score_pv", "cal_composite", + "cal_rubric_version", "cal_scored_at", "cal_bias_signals", "cal_bump_evaluated", +} +SLOPE_EPSILON = 0.02 +MIN_BASELINE_PRIORS = 3 +MIN_TREND_POINTS = 3 +UNKNOWN_ACCOUNT = "__unknown__" + + +def die(msg): + print(json.dumps({"ok": False, "error": msg}, ensure_ascii=False)) + sys.exit(1) + + +def metric_columns(conn, table): + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + cols = [] + for _, name, ctype, *_ in rows: + if name in NON_METRIC_COLS: + continue + if (ctype or "").upper().startswith(("TEXT", "BLOB")): + continue + cols.append(name) + return cols + + +def parse_args(): + ap = argparse.ArgumentParser() + ap.add_argument("--workspace", default=os.environ.get("PUBLISHED_TRACK_ROOT", os.getcwd())) + ap.add_argument("--platform", required=True) + ap.add_argument("--check", action="store_true", help="只做阈值检查,不聚合") + ap.add_argument("--mark-evaluated", action="store_true", help="标记 perf_evaluated=1") + ap.add_argument("--ids", default="", help="--mark-evaluated 用,逗号分隔的记录 id") + ap.add_argument("--dna-id", default="", help="只处理指定 DNA(聚合模式)") + ap.add_argument("--force", action="store_true", help="忽略 min_samples 阈值强制评估(用户手动触发兜底)") + ap.add_argument("--min-samples", type=int, default=5) + ap.add_argument("--mature-days", type=int, default=3) + ap.add_argument("--baseline-window", type=int, default=10) + return ap.parse_args() + + +def main(): + args = parse_args() + db_path = os.path.join(args.workspace, "db", "published_track.db") + if not os.path.isfile(db_path): + die(f"published_track.db not found at {db_path}(先跑 published-track init-db;agent 须从 workspace 根调用)") + table = f"pub_{args.platform}" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + exists = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,) + ).fetchone() + if not exists: + die(f"unknown platform: {args.platform} (table {table} not found)") + cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + if "dna_id" not in cols or "perf_evaluated" not in cols: + die(f"{table} 缺少 v3 列(dna_id/perf_evaluated),先跑 published-track migrate-v3") + + metrics = metric_columns(conn, table) + today = date.today() + cutoff = (today - timedelta(days=args.mature_days)).isoformat() + + # ── 模式 3:标记已评估 ── + if args.mark_evaluated: + ids = [x.strip() for x in args.ids.split(",") if x.strip()] + if not ids: + die("--mark-evaluated 需要 --ids 1,2,3") + bad = [i for i in ids if not i.isdigit()] + if bad: + die(f"非法 id: {bad}") + marks = ",".join(ids) + cur = conn.execute(f"UPDATE {table} SET perf_evaluated=1 WHERE id IN ({marks})") + conn.commit() + print(json.dumps({"ok": True, "mode": "mark_evaluated", "platform": args.platform, + "marked": cur.rowcount, "ids": [int(i) for i in ids]}, ensure_ascii=False)) + return + + pending_where = "dna_id IS NOT NULL AND dna_id != '' AND perf_evaluated=0 AND publish_date <= ?" + + # ── 模式 1:廉价阈值检查 ── + if args.check: + rows = conn.execute( + f"SELECT dna_id, COUNT(*) AS n FROM {table} WHERE {pending_where} GROUP BY dna_id ORDER BY n DESC", + (cutoff,), + ).fetchall() + dnas = [] + for r in rows: + dnas.append({"dna_id": r["dna_id"], "pending": r["n"], + "triggered": r["n"] >= args.min_samples}) + print(json.dumps({"ok": True, "mode": "check", "platform": args.platform, + "mature_cutoff": cutoff, "min_samples": args.min_samples, + "dnas": dnas}, ensure_ascii=False)) + return + + # ── 模式 2:聚合 ── + if args.dna_id: + target_dnas = [args.dna_id] + else: + rows = conn.execute( + f"SELECT dna_id, COUNT(*) AS n FROM {table} WHERE {pending_where} GROUP BY dna_id", + (cutoff,), + ).fetchall() + target_dnas = [r["dna_id"] for r in rows if args.force or r["n"] >= args.min_samples] + + if not target_dnas: + print(json.dumps({"ok": True, "mode": "aggregate", "platform": args.platform, + "mature_cutoff": cutoff, "min_samples": args.min_samples, + "dnas": [], "note": "无触发 DNA(未达阈值或无待评估记录)"}, ensure_ascii=False)) + return + + out_dnas = [] + for dna_id in target_dnas: + batch = conn.execute( + f"SELECT * FROM {table} WHERE dna_id=? AND {pending_where} ORDER BY publish_date ASC, id ASC", + (dna_id, cutoff), + ).fetchall() + if not batch: + continue + total_published = conn.execute( + f"SELECT COUNT(*) AS n FROM {table} WHERE dna_id=?", (dna_id,) + ).fetchone()["n"] + + records_out = [] + for rec in batch: + acct = rec["account"] or UNKNOWN_ACCOUNT + # 同账号「此前」的作品(发布日更早;同日按 id 更早),最多 baseline_window 篇 + priors = conn.execute( + f"""SELECT {",".join(metrics)} FROM {table} + WHERE COALESCE(account,'') = COALESCE(?, '') + AND (publish_date < ? OR (publish_date = ? AND id < ?)) + ORDER BY publish_date DESC, id DESC LIMIT ?""", + (rec["account"], rec["publish_date"], rec["publish_date"], rec["id"], + args.baseline_window), + ).fetchall() + acct_ctx = conn.execute( + f"""SELECT COUNT(*) AS n, MIN(publish_date) AS first_date FROM {table} + WHERE COALESCE(account,'') = COALESCE(?, '')""", + (rec["account"],), + ).fetchone() + entry = { + "id": rec["id"], "title": rec["title"], "source_folder": rec["source_folder"], + "publish_date": rec["publish_date"], + "account": acct if acct != UNKNOWN_ACCOUNT else None, + "account_total_published": acct_ctx["n"], + "account_first_publish": acct_ctx["first_date"], + "metrics": {m: (rec[m] or 0) for m in metrics}, + } + if len(priors) < MIN_BASELINE_PRIORS: + entry["baseline_insufficient"] = True + entry["baseline"] = None + entry["ratios"] = None + entry["prior_count"] = len(priors) + else: + entry["baseline_insufficient"] = False + entry["prior_count"] = len(priors) + baseline, ratios = {}, {} + for m in metrics: + vals = [(p[m] or 0) for p in priors] + base = sum(vals) / len(vals) + baseline[m] = round(base, 2) + ratios[m] = round((rec[m] or 0) / base, 3) if base > 0 else None + entry["baseline"] = baseline + entry["ratios"] = ratios + records_out.append(entry) + + # 趋势:仅用基线充足的记录,按时间序对比值做最小二乘斜率 + trends, trend_insufficient = {}, [] + enough = [r for r in records_out if not r.get("baseline_insufficient")] + for m in metrics: + pts = [r["ratios"][m] for r in enough if r["ratios"] and r["ratios"][m] is not None] + if len(pts) < MIN_TREND_POINTS: + trend_insufficient.append(m) + continue + n = len(pts) + xs = list(range(n)) + mx, my = sum(xs) / n, sum(pts) / n + denom = sum((x - mx) ** 2 for x in xs) + slope = sum((x - mx) * (y - my) for x, y in zip(xs, pts)) / denom if denom else 0.0 + direction = "up" if slope > SLOPE_EPSILON else ("down" if slope < -SLOPE_EPSILON else "flat") + trends[m] = {"direction": direction, "slope": round(slope, 4), + "points": n, "mean_ratio": round(my, 3)} + + out_dnas.append({ + "dna_id": dna_id, + "pending_count": len(records_out), + "total_published": total_published, + "records": records_out, + "trends": trends, + "trend_insufficient": trend_insufficient, + }) + + print(json.dumps({ + "ok": True, "mode": "aggregate", "platform": args.platform, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "params": {"min_samples": args.min_samples, "mature_days": args.mature_days, + "baseline_window": args.baseline_window, "mature_cutoff": cutoff, + "forced": args.force}, + "metrics": metrics, + "dnas": out_dnas, + }, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/crews/main/skills/content-calibrator/scripts/dna-eval.sh b/crews/main/skills/content-calibrator/scripts/dna-eval.sh new file mode 100755 index 00000000..a05a35cc --- /dev/null +++ b/crews/main/skills/content-calibrator/scripts/dna-eval.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# dna-eval.sh — DNA 表现评估聚合引擎 wrapper +# 让 agent 走 PATH 调用,零路径拼接。须从 workspace 根调用(PWD 推导 ROOT)。 +# +# 用法: +# dna-eval.sh --platform --check # 廉价阈值检查(heartbeat 每日) +# dna-eval.sh --platform # 聚合触发 DNA 的证据 +# dna-eval.sh --platform --dna-id --force # 手动触发(不达阈值也评估) +# dna-eval.sh --platform --mark-evaluated --ids 3,4,5 # 评估完成后标记 +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" +# 默认从 PWD 推导 ROOT(agent 从 workspace 根调用) +export PUBLISHED_TRACK_ROOT="${PUBLISHED_TRACK_ROOT:-$PWD}" +exec python3 "$SCRIPT_DIR/dna-eval.py" "$@" diff --git a/crews/main/skills/content-calibrator/scripts/import-viral-chaser.sh b/crews/main/skills/content-calibrator/scripts/import-viral-chaser.sh deleted file mode 100755 index e4c99c51..00000000 --- a/crews/main/skills/content-calibrator/scripts/import-viral-chaser.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -# import-viral-chaser.sh — 将 viral-chaser 追爆报告导入为指定平台的对标信号 -# 用法: import-viral-chaser.sh --platform -set -euo pipefail - -WORKSPACE="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/../../.." &> /dev/null && pwd )" -CAL_ROOT="$WORKSPACE/calibration" - -PLATFORM="" -REPORT_PATH="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --platform) PLATFORM="$2"; shift 2 ;; - *) REPORT_PATH="$1"; shift ;; - esac -done - -if [[ -z "$PLATFORM" || -z "$REPORT_PATH" ]]; then - echo "用法: import-viral-chaser.sh --platform " - echo " platform_id: wx_mp | xhs | zhihu | bilibili | douyin | kuaishou | toutiao | youtube" - echo " report-path: viral-chaser 追爆报告.md 的路径" - echo "" - echo "示例:" - echo " import-viral-chaser.sh --platform douyin output_videos/douyin-7389abc/追爆报告.md" - exit 1 -fi - -CAL_DIR="$CAL_ROOT/$PLATFORM" -if [[ ! -d "$CAL_DIR" ]]; then - echo "❌ 平台 $PLATFORM 的校准目录不存在: $CAL_DIR" - echo " 先运行 init.sh --platform $PLATFORM" - exit 1 -fi - -if [[ ! -f "$REPORT_PATH" ]]; then - echo "❌ 报告文件不存在: $REPORT_PATH" - exit 1 -fi - -BENCHMARK_FILE="$CAL_DIR/benchmark.md" -if [[ ! -f "$BENCHMARK_FILE" ]]; then - echo "❌ benchmark.md 不存在,先运行 init.sh --platform $PLATFORM" - exit 1 -fi - -echo "🎯 导入追爆报告为对标信号 — 平台: $PLATFORM" -echo " 报告: $REPORT_PATH" - -# 提取报告关键信息 -REPORT_CONTENT=$(cat "$REPORT_PATH") - -# 提取平台 -REPORT_PLATFORM=$(echo "$REPORT_CONTENT" | grep -oP '(?<=平台[::]\s*)\S+' | head -1 || echo "unknown") -# 提取标题 -TITLE=$(echo "$REPORT_CONTENT" | grep -oP '(?<=标题[::]\s*).*' | head -1 || echo "unknown") -# 提取播放量 -PLAYS=$(echo "$REPORT_CONTENT" | grep -oP '(?<=播放[::]\s*)[\d.]+[wW万]?' | head -1 || echo "N/A") - -TIMESTAMP=$(date -Iseconds) - -# 追加到该平台的 benchmark.md -cat >> "$BENCHMARK_FILE" << EOF - ---- - -### 追爆对标 — $TITLE ($REPORT_PLATFORM) - -- **来源**: viral-chaser 追爆报告 -- **导入平台**: $PLATFORM -- **导入时间**: $TIMESTAMP -- **播放量**: $PLAYS -- **报告路径**: $REPORT_PATH - -**Pattern 提炼**(由 agent 从报告中分析): -- 结构 pattern: (待 agent 分析填充) -- 开头方式: (待分析) -- 转折技巧: (待分析) -- 金句模式: (待分析) -- 互动钩子: (待分析) - -**Rubric 信号**(对当前 rubric 维度的启示): -- (待 agent 从报告数据中提炼,如"高 ER + 高 HP → 高流量") - -EOF - -echo "" -echo "✅ 已追加到 calibration/$PLATFORM/benchmark.md" -echo "" -echo "下一步: 让 agent 分析追爆报告,填充 pattern 和 rubric 信号" diff --git a/crews/main/skills/content-calibrator/scripts/init.sh b/crews/main/skills/content-calibrator/scripts/init.sh index 81c5456e..43cb0391 100755 --- a/crews/main/skills/content-calibrator/scripts/init.sh +++ b/crews/main/skills/content-calibrator/scripts/init.sh @@ -1,12 +1,11 @@ #!/usr/bin/env bash -# content-calibrator init — 为指定平台创建校准目录与平台数据文件 -# 首次调用时同时创建根级统一 rubric(rubric_notes.md / rubric-memo.md / .cheat-state.json) +# content-calibrator init — 为指定平台创建校准数据目录(baseline / audience / benchmark) # 用法: init.sh --platform # platform_id: wx_mp | wx_channel | xhs | zhihu | bilibili | douyin | kuaishou | toutiao | youtube +# 幂等:已存在的文件跳过。 set -euo pipefail WORKSPACE="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/../../.." &> /dev/null && pwd )" -CAL_ROOT="$WORKSPACE/calibration" PLATFORM="" @@ -41,99 +40,25 @@ if ! echo "$VALID_PLATFORMS" | grep -qw "$PLATFORM"; then exit 1 fi -echo "🔧 初始化 Content Calibrator — $PLATFORM" +CAL_DIR="$WORKSPACE/$PLATFORM/calibration" + +echo "🔧 初始化 Content Calibrator(DNA 表现评估)— $PLATFORM" echo " 工作区: $WORKSPACE" +echo " 校准目录: $CAL_DIR" echo "" -# ── 1. 根级统一 rubric(若不存在)── -mkdir -p "$CAL_ROOT" -if [[ ! -f "$CAL_ROOT/rubric_notes.md" ]]; then - echo " 创建根级统一 rubric(v0)" - cat > "$CAL_ROOT/rubric_notes.md" <<'RUBRIC' -# Rubric Notes — 评分公式(统一) - -> **当前版本**: v0 -> **适用范围**: 全平台统一(一个作品一个打分 ⇒ 一个评分标准) -> **blind sub-agent 可读此文件**;rubric-memo / .cheat-state / audience / benchmark / 各 work 的 retro 不可读。 - -## 当前评分维度 - -| 维度 | 代号 | 0 分 | 5 分 | 权重 | -|------|------|------|------|------| -| 情感共鸣 | ER | 纯信息罗列,无情感触点 | 读者强烈代入"说的就是我" | ×1.5 | -| 钩子强度 | HP | 标题平庸,开头无悬念 | 标题/开头一句话锁定注意力 | ×1.5 | -| 社会议题共振 | SR | 纯个人/产品向 | 触及当下社会讨论,有立场可议 | ×1.5 | -| 金句密度 | QL | 全文无独立可传播表达 | ≥3 句可脱离上下文独立传播的金句 | ×1.0 | -| 叙事性 | NA | 纯观点堆砌 | 清晰起承转合 | ×1.0 | -| 受众广度 | AB | 极窄垂直 | 跨人群普适 | ×1.0 | -| 实用价值 | PV | 纯情绪/观点 | 可获得具体方法/工具/步骤 | ×1.0 | - -## 综合分公式 - -composite = (ER×1.5 + HP×1.5 + SR×1.5 + QL + NA + AB + PV) / 8.5 × 2.0 - -## 版本速查 - -| 版本 | 公式签名 | 日期 | -|------|---------|------| -| v0 | ER1.5+HP1.5+SR1.5+QL+NA+AB+PV / 8.5×2 | 初始 | -RUBRIC -else - echo " 根级 rubric 已存在,跳过" -fi - -if [[ ! -f "$CAL_ROOT/rubric-memo.md" ]]; then - cat > "$CAL_ROOT/rubric-memo.md" <<'MEMO' -# Rubric Memo — 观察记录(统一) - -> **blind sub-agent 硬禁读此文件**。被推翻/吸收的观察删除,git history 是档案。 - -## 观察记录 - -(复盘后观察追加于此。每条观察必须可追溯到具体作品 + 平台数据点。) - -## Bump 升级 Memo - -(每次 rubric 升级后,append 升级详情含证据+诊断。) -MEMO -fi - -if [[ ! -f "$CAL_ROOT/.cheat-state.json" ]]; then - cat > "$CAL_ROOT/.cheat-state.json" <<'STATE' -{ - "schema_version": 3, - "scope": "global", - "rubric_version": "v0", - "mode": "cold-start", - "calibration_samples": 0, - "retro_window_days": 3, - "last_bump_at": null, - "last_bump_self_audited": null, - "calibration_samples_at_last_bump": 0, - "score_threshold": 0 -} -STATE -fi - -# ── 2. 平台数据目录 ── -CAL_DIR="$CAL_ROOT/$PLATFORM" mkdir -p "$CAL_DIR" -# 平台目录建 rubric_notes.md 软链 → 根级统一 rubric(blind subagent 可能按平台目录找 rubric, -# 软链保证它无论从根级还是平台路径都读到同一份;单一事实源仍是根级文件)。幂等。 -if [[ -f "$CAL_ROOT/rubric_notes.md" && ! -e "$CAL_DIR/rubric_notes.md" ]]; then - ln -s ../rubric_notes.md "$CAL_DIR/rubric_notes.md" -elif [[ -L "$CAL_DIR/rubric_notes.md" && "$(readlink "$CAL_DIR/rubric_notes.md")" != "../rubric_notes.md" ]]; then - rm -f "$CAL_DIR/rubric_notes.md" - ln -s ../rubric_notes.md "$CAL_DIR/rubric_notes.md" -fi - -if [[ -f "$CAL_DIR/.platform-state.json" ]]; then - echo "✅ 平台 $PLATFORM 的校准已启用(.platform-state.json 已存在)" - exit 0 +# 兼容旧点号命名:存量 .platform-state.json 自动改名(幂等) +if [[ -f "$CAL_DIR/.platform-state.json" && ! -f "$CAL_DIR/platform-state.json" ]]; then + mv "$CAL_DIR/.platform-state.json" "$CAL_DIR/platform-state.json" + echo " 迁移 .platform-state.json → platform-state.json" fi -cat > "$CAL_DIR/.platform-state.json" < "$CAL_DIR/platform-state.json" < "$CAL_DIR/.platform-state.json" < "$CAL_DIR/audience.md" <<'AUD' +if [[ ! -f "$CAL_DIR/audience.md" ]]; then + cat > "$CAL_DIR/audience.md" <<'AUD' # Audience — 受众画像 -> 从复盘评论聚类派生。blind sub-agent **不可读**此文件。 +> 从互动数据与评论聚类派生,供 DNA 表现评估归因时参考。 ## 基本画像 -(复盘后从评论关键词聚类填充。) +(数据积累后从评论关键词聚类填充。) ## 互动偏好 (哪些类型的内容获得更多互动?哪些评论模因反复出现?) AUD - -cat > "$CAL_DIR/benchmark.md" <<'BM' -# Benchmark — 对标账号 - -> 导入对标账号后,记录对标信号和 pattern。由 LearnFrom 操作维护。 - -## 对标账号列表 - -(暂无。运行"导入对标"添加。) - -## Pattern 提炼 - -(从对标内容中提取的结构 pattern。) -BM + echo " 创建 audience.md" +fi echo "✅ 初始化完成 — 平台: $PLATFORM" echo "" echo "下一步:" -echo " 1. 对已有发布内容做首次复盘 → 积累校准样本" -echo " 2. 导入对标账号 → 获取初始 rubric 信号" -echo " 3. 对新稿子打分+预测 → 开始校准循环" +echo " 1. 内容生产绑定 DNA(生产环节写 dna-meta.json,record.sh 自动落 dna_id)" +echo " 2. 每日采集互动数据(published-track fetch-and-update-metrics.sh)" +echo " 3. 每个(平台, DNA)累积 ≥5 条成熟记录后跑 dna-eval.sh 评估" diff --git a/crews/main/skills/content-calibrator/scripts/score-and-record.sh b/crews/main/skills/content-calibrator/scripts/score-and-record.sh deleted file mode 100755 index d1c9b366..00000000 --- a/crews/main/skills/content-calibrator/scripts/score-and-record.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# score-and-record.sh — 已合并入 published-track/scripts/record.sh(薄 wrapper) -# -# record.sh 现在统一处理:默认从 /calibration/score.json 读分(cal_enabled=1); -# 缺失则报错;--no-cal 显式跳过(cal_enabled=0)。本脚本保留为兼容入口,转调 record.sh。 -# -# 打分的强制门(blind sub-agent + 阈值)在发布技能流程里执行,见各发布技能 -# SKILL.md 的"打分+盲预测"段与 published-track/SKILL.md 块一·流程 1A。 -set -euo pipefail - -echo "ℹ️ score-and-record.sh 已合并入 record.sh,本调用转调 record.sh(兼容保留)" >&2 -exec bash "$(dirname "$0")/../../published-track/scripts/record.sh" "$@" diff --git a/crews/main/skills/content-calibrator/scripts/score-only.sh b/crews/main/skills/content-calibrator/scripts/score-only.sh deleted file mode 100755 index 730fc02e..00000000 --- a/crews/main/skills/content-calibrator/scripts/score-only.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -# score-only.sh — 仅打分不记录到 DB,用于 Agent 自查 -# 输出打分结果到 stdout(JSON),不写入 published-track DB -# -# 用法: -# score-only.sh --content-path [--platform ] \ -# --cal-er ? --cal-hp ? --cal-sr ? --cal-ql ? --cal-na ? --cal-ab ? --cal-pv ? -# -# --platform 可选:per-work 下阈值是全局的;--platform 仅用于校验该平台是否启用 calibration。 -# -# Agent 调用此脚本时,应同时传入打分参数(由 Agent LLM 打分后传入): -# score-only.sh --content-path output_articles/xxx/article.md \ -# --cal-er 3 --cal-hp 4 --cal-sr 3 --cal-ql 3 --cal-na 2 --cal-ab 4 --cal-pv 3 -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -CAL_ROOT="$ROOT/calibration" - -PLATFORM="" CONTENT_PATH="" -CAL_ER="" CAL_HP="" CAL_SR="" CAL_QL="" CAL_NA="" CAL_AB="" CAL_PV="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --platform) PLATFORM="$2"; shift 2 ;; - --content-path) CONTENT_PATH="$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 ;; - *) echo "{\"ok\":false,\"error\":\"unknown arg: $1\"}"; exit 1 ;; - esac -done - -# --platform 可选:per-work 下阈值是全局的,--platform 仅用于校验该平台是否启用 calibration -if [ -n "$PLATFORM" ]; then - CAL_DIR="$CAL_ROOT/$PLATFORM" - if [ ! -f "$CAL_DIR/.platform-state.json" ]; then - echo "{\"ok\":false,\"error\":\"platform $PLATFORM has content-calibrator disabled. Enable with cal-toggle.sh --platform $PLATFORM --enable\"}" - exit 1 - fi -fi - -# rubric_version + score_threshold 均取自根级统一 .cheat-state.json(per-work 全局阈值) -RUBRIC_VERSION=$(python3 -c "import json; print(json.load(open('$CAL_ROOT/.cheat-state.json')).get('rubric_version','v0'))" 2>/dev/null || echo "v0") -SCORE_THRESHOLD=$(python3 -c "import json; print(json.load(open('$CAL_ROOT/.cheat-state.json')).get('score_threshold',0))" 2>/dev/null || echo 0) - -# 验证打分参数 -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 - val="${!var_name}" - 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 [ "$HAS_SCORES" -eq 0 ]; then - echo '{"ok":false,"error":"no scores provided. Agent must score the content (ER/HP/SR/QL/NA/AB/PV, each 0-5) and pass via --cal-er etc."}' - exit 1 -fi - -# 算 composite -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=$(python3 -c " -er=$er; hp=$hp; sr=$sr; ql=$ql; na=$na; ab=$ab; pv=$pv -composite = (er*1.5 + hp*1.5 + sr*1.5 + ql + na + ab + pv) / 8.5 * 2.0 -print(f'{composite:.2f}') -") - -# 找最弱维度 -declare -A WEIGHTS=([ER]=1.5 [HP]=1.5 [SR]=1.5 [QL]=1.0 [NA]=1.0 [AB]=1.0 [PV]=1.0) -declare -A SCORES=([ER]="$er" [HP]="$hp" [SR]="$sr" [QL]="$ql" [NA]="$na" [AB]="$ab" [PV]="$pv") - -worst_dim="" -worst_contrib=999 -for dim in ER HP SR QL NA AB PV; do - contrib=$(python3 -c "print(${SCORES[$dim]} * ${WEIGHTS[$dim]})") - if python3 -c "exit(0 if $contrib < $worst_contrib else 1)"; then - worst_contrib=$contrib - worst_dim=$dim - fi -done - -# 输出 JSON(不写 DB)+ 阈值门判定 -python3 -c " -import json -scores = {'ER': $er, 'HP': $hp, 'SR': $sr, 'QL': $ql, 'NA': $na, 'AB': $ab, 'PV': $pv} -threshold = $SCORE_THRESHOLD -failing = [d for d, v in scores.items() if v <= threshold] -result = { - 'ok': True, - 'action': 'score_only', - 'platform': '$PLATFORM', - 'rubric_version': '$RUBRIC_VERSION', - 'scores': scores, - 'composite': $COMPOSITE, - 'worst_dim': '$worst_dim', - 'worst_contrib': $worst_contrib, - 'score_threshold': threshold, - 'passed': len(failing) == 0, - 'failing_dims': failing, - 'recorded': False -} -print(json.dumps(result, ensure_ascii=False)) -" diff --git a/crews/main/skills/content-calibrator/scripts/validate-rubric.py b/crews/main/skills/content-calibrator/scripts/validate-rubric.py deleted file mode 100755 index 05c119be..00000000 --- a/crews/main/skills/content-calibrator/scripts/validate-rubric.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -"""validate-rubric.py — 新 rubric 公式合格性验证 - -对比新旧公式在同一批作品上的偏差信号数。新公式偏差信号降幅 ≥ 阈值 → pass。 - -工作方式: -1. 从 DB 查最新发布且有数据的 N 篇作品的旧分数 + 实际指标 → 算旧偏差信号数 -2. 读 Agent 批量重打的新分数(JSON 文件,由 blind sub-agent 产出) -3. 用同一归一化逻辑算新偏差信号数 -4. 降幅 = (old - new) / old ≥ 阈值(默认 30%)→ pass=true - -Usage: - python3 validate-rubric.py --new-scores /tmp/new-scores.json - python3 validate-rubric.py --new-scores /tmp/new-scores.json --reduction-threshold 0.3 - -new-scores.json 格式: -[ - {"source_folder": "output_articles/xxx", "scores": {"er":3,"hp":4,"sr":2,"ql":4,"na":3,"ab":4,"pv":3}}, - ... -] -""" -from __future__ import annotations - -import argparse -import json -import os -import sqlite3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared")) -from runtime_root import resolve_runtime_root # noqa: E402 - -# ── 路径 ───────────────────────────────────────────────────────────────────── - -ROOT = resolve_runtime_root(Path(__file__).resolve().parent.parent.parent.parent) -DB = ROOT / "db" / "published_track.db" - -# ── 常量(与 detect-bump-signals.py 一致)───────────────────────────────────── - -DIMENSIONS = ["er", "hp", "sr", "ql", "na", "ab", "pv"] - -METRIC_COLUMNS = { - "reads", "likes", "comments", "shares", "favorites", - "plays", "views", "danmaku", "coins", "upvotes", - "impressions", "reach", "saves", "retweets", "replies", - "bookmarks", "reposts", -} - -SCORE_HIGH = 3 -SCORE_LOW = 2 -ACTUAL_HIGH = 3 -ACTUAL_LOW = 2 - - -# ── 归一化(与 detect-bump-signals.py 一致)────────────────────────────────── - -def engagement_to_score(total: int) -> int: - if total <= 0: - return 0 - elif total <= 10: - return 1 - elif total <= 50: - return 2 - elif total <= 200: - return 3 - elif total <= 1000: - return 4 - else: - return 5 - - -def count_bias_signals(dim_scores: dict[str, int], actual_score: int) -> int: - """返回偏差信号数(高估 + 低估)。""" - count = 0 - for dim, score in dim_scores.items(): - if score >= SCORE_HIGH and actual_score <= ACTUAL_LOW: - count += 1 - elif score <= SCORE_LOW and actual_score >= ACTUAL_HIGH: - count += 1 - return count - - -# ── DB 查询 ────────────────────────────────────────────────────────────────── - -def get_all_platform_tables(conn: sqlite3.Connection) -> list[str]: - return [r[0] for r in conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'pub_%'" - )] - - -def get_metric_columns(conn: sqlite3.Connection, table: str) -> list[str]: - cols = [r[1] for r in conn.execute(f"PRAGMA table_info({table})")] - return sorted(METRIC_COLUMNS & set(cols)) - - -def query_work_metrics(conn: sqlite3.Connection, source_folders: set[str]) -> dict[str, list[dict]]: - """查各 work 在各平台的互动指标。返回 {source_folder: [{platform, total_engagement, actual_score}]}""" - tables = get_all_platform_tables(conn) - result: dict[str, list[dict]] = {sf: [] for sf in source_folders} - - for table in tables: - platform = table.removeprefix("pub_") - metric_cols = get_metric_columns(conn, table) - if not metric_cols: - continue - col_list = ", ".join(f"COALESCE({c}, 0) AS {c}" for c in metric_cols) - placeholders = ",".join("?" * len(source_folders)) - rows = conn.execute( - f"SELECT source_folder, {col_list} FROM {table} WHERE source_folder IN ({placeholders})", - list(source_folders), - ).fetchall() - for row in rows: - sf = row[0] - total = sum(row[i] or 0 for i in range(1, len(metric_cols) + 1)) - actual_score = engagement_to_score(total) - result[sf].append({ - "platform": platform, - "total_engagement": total, - "actual_score": actual_score, - }) - - return result - - -def get_old_scores(conn: sqlite3.Connection, source_folders: set[str]) -> dict[str, dict[str, int]]: - """从 DB 查各 work 的旧 cal_score_* 分数。返回 {source_folder: {er:3, hp:4, ...}}""" - tables = get_all_platform_tables(conn) - result: dict[str, dict[str, int]] = {} - - for table in tables: - placeholders = ",".join("?" * len(source_folders)) - rows = conn.execute( - f"""SELECT source_folder, cal_score_er, cal_score_hp, cal_score_sr, - cal_score_ql, cal_score_na, cal_score_ab, cal_score_pv - FROM {table} - WHERE source_folder IN ({placeholders}) AND cal_score_er IS NOT NULL - GROUP BY source_folder""", - list(source_folders), - ).fetchall() - for row in rows: - sf = row[0] - if sf not in result: # 取第一个有分数的记录 - result[sf] = {} - for i, dim in enumerate(DIMENSIONS): - val = row[i + 1] - if val is not None: - result[sf][dim] = int(val) - - return result - - -# ── 主逻辑 ─────────────────────────────────────────────────────────────────── - -def validate(new_scores_path: str, sample_size: int, reduction_threshold: float) -> dict: - # 读新分数 - new_scores_data = json.loads(Path(new_scores_path).read_text(encoding="utf-8")) - source_folders = {item["source_folder"] for item in new_scores_data} - - if len(source_folders) < 3: - return {"pass": False, "reason": f"样本不足:仅 {len(source_folders)} 篇,最少需要 3 篇"} - - if not DB.exists(): - return {"pass": False, "reason": f"DB not found: {DB}"} - - conn = sqlite3.connect(str(DB)) - try: - # 查各 work 的实际指标 - work_metrics = query_work_metrics(conn, source_folders) - - # 查旧分数 - old_scores = get_old_scores(conn, source_folders) - - # 算旧公式偏差信号数 - old_signal_count = 0 - old_data_points = 0 - for sf, metrics_list in work_metrics.items(): - if sf not in old_scores: - continue - for m in metrics_list: - old_data_points += 1 - old_signal_count += count_bias_signals(old_scores[sf], m["actual_score"]) - - # 算新公式偏差信号数 - new_scores_map = {item["source_folder"]: item["scores"] for item in new_scores_data} - new_signal_count = 0 - new_data_points = 0 - for sf, metrics_list in work_metrics.items(): - if sf not in new_scores_map: - continue - for m in metrics_list: - new_data_points += 1 - new_signal_count += count_bias_signals(new_scores_map[sf], m["actual_score"]) - - # 判定:降幅 ≥ 阈值 → pass - reduction = old_signal_count - new_signal_count - if old_signal_count == 0: - passed = False - reason = "旧公式偏差信号数已为 0,无升级必要" - reduction_ratio = 0.0 - else: - reduction_ratio = reduction / old_signal_count - passed = reduction_ratio >= reduction_threshold - if passed: - reason = f"偏差信号降幅 {reduction_ratio:.0%} ≥ 阈值 {reduction_threshold:.0%}" - else: - reason = f"偏差信号降幅 {reduction_ratio:.0%} < 阈值 {reduction_threshold:.0%}" - - return { - "pass": passed, - "sample_size": len(source_folders), - "old_signals": old_signal_count, - "new_signals": new_signal_count, - "old_data_points": old_data_points, - "new_data_points": new_data_points, - "reduction": reduction, - "reduction_ratio": round(reduction_ratio, 4), - "reduction_threshold": reduction_threshold, - "reason": reason, - } - finally: - conn.close() - - -def main() -> int: - parser = argparse.ArgumentParser( - prog="validate-rubric", - description="新 rubric 公式合格性验证", - ) - parser.add_argument("--new-scores", required=True, help="新分数 JSON 文件路径") - parser.add_argument("--sample-size", type=int, default=10, help="验证样本数(默认 10)") - parser.add_argument( - "--reduction-threshold", - type=float, - default=0.3, - help="偏差信号降幅阈值(默认 0.3=30%%,新公式信号数需 ≤ 旧 × (1-阈值))", - ) - args = parser.parse_args() - - result = validate(args.new_scores, args.sample_size, args.reduction_threshold) - json.dump(result, sys.stdout, ensure_ascii=False, indent=2) - sys.stdout.write("\n") - return 0 if result.get("pass") else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/crews/main/skills/content-calibrator/scripts/validate-rubric.sh b/crews/main/skills/content-calibrator/scripts/validate-rubric.sh deleted file mode 100755 index 1964bfdc..00000000 --- a/crews/main/skills/content-calibrator/scripts/validate-rubric.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# validate-rubric.sh — 新 rubric 公式合格性验证 wrapper -# 让 agent 走 PATH 调用,零路径拼接。 -set -euo pipefail -SELF="${BASH_SOURCE[0]}" -while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done -SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" -# 默认从 PWD 推导 ROOT(agent 从 workspace 根调用) -export PUBLISHED_TRACK_ROOT="${PUBLISHED_TRACK_ROOT:-$PWD}" -exec python3 "$SCRIPT_DIR/validate-rubric.py" "$@" diff --git a/crews/main/skills/council/SKILL.md b/crews/main/skills/council/SKILL.md index 081ae029..47f07e09 100644 --- a/crews/main/skills/council/SKILL.md +++ b/crews/main/skills/council/SKILL.md @@ -42,9 +42,9 @@ metadata: | 需求 | 改用 | |------|------| -| 制作投资人材料 | investor-materials skill | -| 搜索/筛选投资人 | market-research + investor-outreach | -| 记录投资人进展 | ir-record skill | +| 制作投资人材料 | `expert-ir` 的 Investor Materials Workflow | +| 搜索/筛选投资人 | `market-research` + `expert-ir` 的 Investor Hunting Workflow | +| 记录投资人进展 | `expert-ir` 包内 `ir-record` 工具 | | 明确的执行任务 | 直接执行 | | 纯事实性问题 | 直接回答 | @@ -187,3 +187,4 @@ Prompt 模板: - 把完整对话历史喂给子 agent - 在最终裁决中隐藏异见 - 每次决策都写入文件不管重不重要 +- 不等所有subagent返回结论后,就匆忙下出结论。 diff --git a/crews/main/skills/douyin-publish/SKILL.md b/crews/main/skills/douyin-publish/SKILL.md deleted file mode 100644 index 91e82819..00000000 --- a/crews/main/skills/douyin-publish/SKILL.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -name: douyin-publish -description: 通过浏览器自动化发布视频到抖音创作者中心。纯浏览器操作方案。 -metadata: - openclaw: - emoji: 🎤 - requires: - bins: - - python3 - - camoufox-cli ---- - -# 抖音内容发布 - -通过 **camoufox-cli** 持久化 session `douyin`(一个且只有一个持久化 session,fail-first 队列:同 session 已有命令在跑时新命令直接 fail)在抖音创作者中心发布视频。 - -> **纯浏览器操作方案**:本 skill 自身不吃 cookie,**严禁** `cookies import` 造登录会话--浏览器操作一律走 login-manager 真实登录后的**持久化 session `douyin`**(登录态 + 指纹冻结在 session profile 里)。探活 / 有头登录 / 导出 cookie+UA 全交 login-manager,本 skill 只复用持久化 session 做发布操作。 - ---- - -## 发布前置:open 上传页 + agent 判定登录态(必做) - -抖音 cookie 存在预热机制,直接 `douyin-publish run` 可能因 cookie 未激活而不生效。**每次发布前必须先 open 上传页**(无头 persistent session),由 agent 在此页面根据元素判定登录态,之后再走 `run`。 - -```bash -# 1. open 上传页(无头 persistent session `douyin`) -douyin-publish open-page -# 输出: {"ok": true, "session": "douyin", "url": "...", "hint": "agent 用 camoufox-cli eval/snapshot 判定登录态"} - -# 2. agent 判定登录态 -通过页面元素判定登录态,如用户头像/用户名等。示例: -camoufox-cli --session douyin --persistent --json eval "document.querySelector('头像 selector') ? 'logged_in' : 'not_logged_in'" - -也可以直接截图调用视觉模型判定。 - -# 3a. 判定为已登录 → 走发布 -douyin-publish run --video /path/to/video.mp4 --title "标题" --caption "描述" - -# 3b. 判定为未登录 → 见下方“如果登录失效”处理流程 -``` - -> ⚠️ **`_check_logged_in` 已 mute 成 no-op**(2026-08-04):原版用 URL 跳转 + cookies export 双信号判登录态,实测误判率高(cookie 预热机制、临时 profile 等导致 SESSION_EXPIRED 假阳性)。登录态判定改由 agent 在 open 上传页后自行根据页面元素(用户头像/用户名等)判定。脚本内 `_check_logged_in` 保留签名但不再做任何检查、不再 exit 2。 - ---- - -## 如果登录失效:使用 login-manager 重新登录 - -走 login-manager skill 流程,复用 `douyin` 持久化 session - -```bash -camoufox-cli --session douyin --persistent --headed --json open "https://www.douyin.com" -# 告知用户在窗口里手动完成创作者中心登录,确认后: -login-manager --platform douyin -``` - -login-manager 一条命令闭环导出+验证+落中央存储(供 viral-chaser / published-track 消费)+ close session。本 skill 发布时 douyin-publish run 会使用 `--session douyin --persistent` 重起无头 session。 - ---- - -## 使用方式 - -### 一键全流程 - -```bash -douyin-publish run \ - --video /path/to/video.mp4 \ - --title "视频标题" \ - --caption "视频描述 #话题1 #话题2" -``` - -`run` 内部串:upload → fill → publish → get-link。 - -> ⚠️ **发布前必做 `open-page` + 登录态判定**(见上方"发布前置"章节),否则可能因 cookie 未预热而不生效。 - -### 分步调用(agent 按需) - -```bash -# 1. 上传视频(返回 session 名,后续步骤用) -douyin-publish upload --video video.mp4 - -# 2. 填标题/描述 + 自主声明 -# fill 命令内部自动完成:填标题 -> 填简介 -> 选自主声明"内容由AI生成" -> 点"确定"按钮 -# 自主声明下拉不存在时不阻断(部分账号/页面无此选项) -douyin-publish fill --session --title "标题" --caption "描述" - -# 3. 点发布(注入拦截器捕获 aweme_id 写入 localStorage,返回 aweme_id) -douyin-publish publish --session - -# 4. 取视频链接 -douyin-publish get-link --session -``` - -> **get-link 取链接策略**(2026-07-17 事故修正):发布走 form/导航(非 fetch/XHR),发布页拦截器抓不到 aweme_id。改打作品管理 list API `creator.douyin.com/janus/douyin/creator/pc/work_list` 拿 `aweme_list`,**按 `create_time` 排序取最新**(列表不按时间排,必须自排),拼 `https://www.douyin.com/video/`。`publish` 记 `publish_start` 时刻,筛 `create_time >= publish_start - 120` 锁定本次作品,落 `localStorage.douyin_last_aweme_id` 供 `get-link` 复用。`get-link` 三级策略:① localStorage ② work_list 取全局最新 ③ 管理页 DOM 兜底。`run` 全流程在 `close` session 之前就拿到链接,不依赖 close 后重开。 - -> **注意**:本 skill **没有 `login` 子命令、也没有 `cleanup` 子命令**--执行过程中任何时候发现登录态已失效,重走 login-manager 登录流程。 - ->`upload` open 上传页后会检测「你还有上次未发布的视频,是否继续编辑?」草稿恢复框并点「放弃」清掉,给新发布一个干净上传页。旧草稿在场时新视频上传/发布会被带偏。 - -> **自主声明流程**(2026-07-17 真机确认):点开"请选择自主声明"下拉 -> 选"内容由AI生成" -> **点弹窗右下角粉色"确定"按钮**让声明生效。`fill` 命令已内置此流程。 - ---- - -## 创作者中心 URL - -上传页:`https://creator.douyin.com/creator-micro/content/upload?enter_from=dou_web` - -视频管理页:`https://creator.douyin.com/creator-micro/content/manage`(取链接用) - ---- - -## 必做约束 - -- **用完即 close 持久化 session `douyin`**--登录态 + 指纹冻结在磁盘 profile,不留进程占内存;下次发布 `--session douyin --persistent` 重起无头即恢复。只在 session 卡死时 `camoufox-cli --session douyin --json close` teardown。 -- 同 session 已有命令在跑时,新命令 fail-first(返回 `session douyin 正忙,请等待当前操作完成后再试`)--读到这条文本就等当前操作完成再重试,不要盲试。 -- **严禁 `cookies import`**:浏览器操作不开临时 session 再 import cookie 那一套,会触发平台风控。 -- 执行过程中任何时候发现登录态已失效,则走 login-manager 有头重登流。 -- **不导出 cookie / UA**:导出是 login-manager 的事,本 skill 不调用 `cookies export` / `identity export`。(`_check_logged_in` 内部为验登录态会 `cookies export` 到 /tmp 临时文件读关键字段,非落中央存储。) - -### Exit codes - -| code | 含义 | 调用方动作 | -|------|------|-----------| -| `0` | 发布成功,aweme_id 已捕获,cookie+UA 在 login-manager 中央存储 | 继续下游 | -| `1` | 参数错 / crash / DOM 改版(按钮/input 未找到)/ 上传转码超时 | 排查后重试 | -| `2` | `SESSION_EXPIRED`——未登录或登录态失效(URL 跳登录页 或 cookies 缺 sessionid/sid_tt/uid_tt) | 走 login-manager `--platform douyin` 有头重登后重试 | -| `3` | 发布流程走完但未捕获到 aweme_id——发布可能未真正成功(发布 API 未命中拦截器或被服务端拒) | **人工到管理页核实是否真有新作品**;把 `/tmp/dy-publish-debug-*.json` 回传给研发定位真实发布 API | - -### aweme_id 捕获 + debug 日志 - -`publish` 阶段注入 fetch/XHR 拦截器,**全量捕获**发布期间所有请求响应,深度搜索 `aweme_id`/`item_id`/`video_id` 字段,写入 `localStorage.douyin_last_aweme_id`(同源跨发布→管理导航存活)。同时把所有请求的 URL/method/status/响应片段记到 `localStorage.douyin_publish_debug`,发布后落盘 `/tmp/dy-publish-debug-.json` 供排查。 - -**拦截器是兜底**——发布实际走 form/导航(非 fetch/XHR),拦截器通常抓不到 aweme_id。主路是发布后直接打作品管理 list API `work_list` 拿 `aweme_list`,按 `create_time` 排序取最新(列表不按时间排),筛 `create_time >= publish_start - 120` 锁定本次作品。`work_list` 偶发 `status_code=8`(间歇鉴权失败,非签名/非真掉登,同 session 同 URL 连发通常全 0),helper 内置 3 次重试;3 次全 8 才 `exit 2` 交 login-manager 重登。**aweme_id 未捕获即 `exit 3`,不再误报发布成功。** - ---- - -## Pitfalls - -### pitfall: douyin_login_required_on_creator_center - -- **触发**:访问 `creator.douyin.com` 未登录态 -- **症状**:页面跳到 `creator.douyin.com/login` 或出现登录弹窗 -- **workaround**:脚本返回 `exit 2`(session 失效),由调用方走 **login-manager 有头手动重登流**,不在本 skill 内自管重登。 - -### pitfall: real_name_auth_required - -- **触发**:未实名认证的账号 -- **症状**:创作者中心提示"请先完成实名认证"才能发布 -- **workaround**:用户自己走实名认证流程(脚本帮不上) - -### pitfall: video_too_long_or_wrong_format - -- **触发**:上传非 mp4 / mov 格式,或视频时长超限 -- **症状**:上传后转码失败 / 客户端拒收 -- **workaround**:转 mp4 + 检查时长(抖音支持最长 15 分钟) - -### pitfall: dom_changes_creator_center - -- **触发**:抖音创作者中心前端改版 -- **症状**:selector 找不到(input / button 位置变化) -- **workaround**:部署后真机验证更新 selector(见 `docs/post-deploy-verification.md`) - -### pitfall: upload_transcode_timeout - -- **触发**:视频上传后转码超时(大文件 / 网络波动) -- **症状**:`camoufox_wait_for_selector` 轮询标题表单超时,脚本报 `视频上传/转码超时(标题表单未出现)` -- **workaround**:检查视频大小(建议 < 100MB);超时后截图排查是 DOM 改版还是转码慢;确认 DOM 已渲染后可用分步命令(`fill` / `publish`)手动继续 - -### pitfall: ai_declaration_confirm - -- **触发**:选完自主声明"内容由AI生成"后未点"确定"按钮 -- **症状**:声明弹窗卡住,发布按钮被遮挡,无法点发布 -- **workaround**:`fill` 命令已内置点"确定"步骤;分步手动操作时需注意选完声明后必须点"确定" - -### pitfall: rate_limit_after_burst_publish - -- **触发**:短时间内连续发布多条 -- **症状**:平台风控 / 上传被拒 / 提示"操作过于频繁" -- **workaround**:每天 ≤ 5 条;触发后 30 分钟内不重试 - ---- - -## Notes - -- Docker 内对内 crew exec full(无 allowlist 限制) -- 限频建议:单抖音号每 24h ≤ 5 条发布;触发风控立即降级 -- 失败回退:浏览器模拟失败 → 维持现状(让用户自己手动发) -- 抖音创作者中心 DOM 改版频繁:selector 需部署后真机验证(见 `docs/post-deploy-verification.md`) diff --git a/crews/main/skills/douyin-publish/scripts/tests/test_publish_douyin.py b/crews/main/skills/douyin-publish/scripts/tests/test_publish_douyin.py deleted file mode 100755 index 8e5a5f1b..00000000 --- a/crews/main/skills/douyin-publish/scripts/tests/test_publish_douyin.py +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for publish_douyin.py (纯浏览器模拟方案,形态仿 wechat-channels-publish). - - Covers: -- 4 个子命令路由(upload / fill / publish / get-link)+ run 一键全流程 -- 纯浏览器操作:本 skill 不自管探活/登录,交 login-manager;脚本只复用持久化 session `douyin` 做发布 -- camoufox-cli 调用模式(open / eval / click / type / set_file / wait) -- 持久化 session 复用(用完即 close,登录态在磁盘 profile,下次重起无头即恢复) -- file 不存在 / 按钮找不到等失败模式 - -All camoufox-cli / subprocess calls are mocked. -""" -import json -import subprocess -import sys -import tempfile -import unittest -from io import StringIO -from pathlib import Path -from unittest import mock - -SCRIPTS_DIR = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(SCRIPTS_DIR)) - -import publish_douyin # noqa: E402 - - -class TestConstants(unittest.TestCase): - def test_upload_url_uses_douyin_creator(self): - self.assertIn("creator.douyin.com", publish_douyin.UPLOAD_URL) - self.assertIn("/creator-micro/content/upload", publish_douyin.UPLOAD_URL) - self.assertIn("enter_from=dou_web", publish_douyin.UPLOAD_URL) - - def test_platform_key(self): - # 持久化 session 名 = 平台 key(探活/登录/导出 cookie+UA 交 login-manager) - self.assertEqual(publish_douyin.PERSISTENT_SESSION, "douyin") - - def test_no_douyin_open_platform_credentials(self): - # Phase 3.2 浏览器模拟方案:不依赖开放平台凭据 - import inspect - src = inspect.getsource(publish_douyin) - # 不应再有 H5 schema / open platform 相关 - self.assertNotIn("open_platform", src.lower().replace(" ", "")) - self.assertNotIn("client_key", src) - self.assertNotIn("client_secret", src) - self.assertNotIn("access_token", src) - # 应该有 browser / camoufox 关键字 - self.assertIn("camoufox", src.lower()) - - -class TestSessionNaming(unittest.TestCase): - def test_session_name_format(self): - name = publish_douyin.session_name("publish") - # douyin-publish-{nonce} / douyin-upload-{nonce} / douyin-run-{nonce} - self.assertTrue(name.startswith("douyin-publish-")) - suffix = name[len("douyin-publish-"):] - self.assertGreater(len(suffix), 0) - - -class TestCmdUpload(unittest.TestCase): - def test_video_not_found_exits_1(self): - with self.assertRaises(SystemExit) as ctx: - publish_douyin.cmd_upload(video="/nonexistent.mp4", session="s1") - self.assertEqual(ctx.exception.code, 1) - - @mock.patch("publish_douyin._check_logged_in") - @mock.patch("publish_douyin.camoufox_wait_for_selector") - @mock.patch("publish_douyin.camoufox_upload") - @mock.patch("publish_douyin.camoufox_open") - def test_successful_upload(self, mock_open, mock_upload, mock_wait, mock_login): - mock_upload.return_value = True - mock_wait.return_value = True - - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "v.mp4" - video.write_bytes(b"video") - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_upload(video=str(video), session="douyin-upload-abc") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - self.assertEqual(result["session"], "douyin-upload-abc") - mock_open.assert_called_once() - mock_login.assert_called_once() # 登录态守卫在 open 后被调用 - - @mock.patch("publish_douyin._check_logged_in") - @mock.patch("publish_douyin.camoufox_upload") - @mock.patch("publish_douyin.camoufox_open") - def test_upload_setfile_fail_exits_1(self, mock_open, mock_upload, mock_login): - mock_upload.return_value = False - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "v.mp4" - video.write_bytes(b"video") - with self.assertRaises(SystemExit) as ctx: - publish_douyin.cmd_upload(video=str(video), session="s1") - self.assertEqual(ctx.exception.code, 1) - - -class TestCheckLoggedIn(unittest.TestCase): - """_check_logged_in 已 mute 成 no-op(2026-08-04)。 - - 原版用 URL 跳转 + cookies export 双信号判登录态,实测误判率高(cookie 预热机制、 - 临时 profile 等导致 SESSION_EXPIRED 假阳性)。改为由 agent 在 open 上传页后自行根据 - 页面元素判定登录态。本函数保留签名但不再做任何检查、不再 exit 2。 - """ - - def test_muted_no_op_does_not_exit(self): - # 无论输入什么,no-op 实现都不应抛 SystemExit - publish_douyin._check_logged_in("douyin") # 不抛即通过 - publish_douyin._check_logged_in("any-session") # 不抛即通过 - - def test_muted_no_op_returns_none(self): - # no-op 实现应返回 None - self.assertIsNone(publish_douyin._check_logged_in("douyin")) - - -class TestFetchNewestAwemeId(unittest.TestCase): - """work_list API 取最新作品:成功 / 鉴权失败重试后 exit 2 / 无作品。""" - - @mock.patch("publish_douyin.camoufox_eval") - def test_success_returns_newest(self, mock_eval): - mock_eval.return_value = '{"sc": 0, "id": "7663480620206542131", "ct": 1784293135, "title": "测试", "count": 5}' - aid, title = publish_douyin._fetch_newest_aweme_id("douyin") - self.assertEqual(aid, "7663480620206542131") - self.assertEqual(title, "测试") - - @mock.patch("publish_douyin.camoufox_eval") - def test_auth_fail_3x_exits_2(self, mock_eval): - # status_code=8 三次(间歇重试仍失败)→ exit 2 SESSION_EXPIRED - mock_eval.return_value = '{"sc": 8, "id": null}' - with self.assertRaises(SystemExit) as ctx: - publish_douyin._fetch_newest_aweme_id("douyin") - self.assertEqual(ctx.exception.code, 2) - self.assertEqual(mock_eval.call_count, 3) - - @mock.patch("publish_douyin.camoufox_eval") - def test_auth_fail_then_recover(self, mock_eval): - # 第一次 sc=8,重试后 sc=0 命中 → 返回 id - mock_eval.side_effect = ['{"sc": 8, "id": null}', '{"sc": 0, "id": "999", "title": "ok"}'] - aid, title = publish_douyin._fetch_newest_aweme_id("douyin") - self.assertEqual(aid, "999") - - @mock.patch("publish_douyin.camoufox_eval") - def test_no_items_returns_none_no_retry(self, mock_eval): - # sc=0 但无作品(since_ts 筛掉所有)→ (None,None),不重试 - mock_eval.return_value = '{"sc": 0, "id": null}' - aid, title = publish_douyin._fetch_newest_aweme_id("douyin", since_ts=9999999999) - self.assertIsNone(aid) - self.assertEqual(mock_eval.call_count, 1) - - -class TestCmdFill(unittest.TestCase): - @mock.patch("publish_douyin.camoufox_eval") - @mock.patch("publish_douyin.camoufox_type_contenteditable") - @mock.patch("publish_douyin.camoufox_type") - def test_fill_title_and_caption(self, mock_type, mock_ce, mock_eval): - mock_type.return_value = True - mock_ce.return_value = True - mock_eval.return_value = "no-select" # 无自主声明区,_select_ai_declaration 直接 True - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_fill(session="s1", title="测试标题", caption="描述 #话题") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - - @mock.patch("publish_douyin.camoufox_type") - def test_fill_title_missing_input_exits_1(self, mock_type): - mock_type.return_value = False - with self.assertRaises(SystemExit) as ctx: - publish_douyin.cmd_fill(session="s1", title="x", caption="") - self.assertEqual(ctx.exception.code, 1) - - -class TestCmdPublish(unittest.TestCase): - @mock.patch("publish_douyin._fetch_newest_aweme_id") - @mock.patch("publish_douyin.Path") - @mock.patch("publish_douyin.camoufox_wait_for_url_contains") - @mock.patch("publish_douyin.camoufox_click_button_by_text") - @mock.patch("publish_douyin.camoufox_eval") - def test_publish_success_returns_aweme_id(self, mock_eval, mock_click, mock_wait, mock_path, mock_fetch): - mock_click.return_value = True - mock_wait.return_value = True - # 拦截器命中 localStorage → 不走 work_list - mock_eval.side_effect = ["intercepted", "123456", "[]"] - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_publish(session="s1") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - self.assertEqual(result["aweme_id"], "123456") - mock_fetch.assert_not_called() - - @mock.patch("publish_douyin._fetch_newest_aweme_id") - @mock.patch("publish_douyin.Path") - @mock.patch("publish_douyin.camoufox_wait_for_url_contains") - @mock.patch("publish_douyin.camoufox_click_button_by_text") - @mock.patch("publish_douyin.camoufox_eval") - def test_publish_interceptor_miss_falls_back_to_work_list(self, mock_eval, mock_click, mock_wait, mock_path, mock_fetch): - # 拦截器 miss(发布走 form/导航)→ work_list API 兜底取最新作品 - mock_click.return_value = True - mock_wait.return_value = True - mock_eval.side_effect = ["intercepted", None, "[]", "ok"] # 拦截注入 + 读captured(miss) + 读debug + 落localStorage - mock_fetch.return_value = ("7663480620206542131", "测试标题") - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_publish(session="s1") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - self.assertEqual(result["aweme_id"], "7663480620206542131") - mock_fetch.assert_called_once() - - @mock.patch("publish_douyin._fetch_newest_aweme_id") - @mock.patch("publish_douyin.Path") - @mock.patch("publish_douyin.camoufox_wait_for_url_contains") - @mock.patch("publish_douyin.camoufox_click_button_by_text") - @mock.patch("publish_douyin.camoufox_eval") - def test_publish_aweme_id_none_exits_3_no_false_success(self, mock_eval, mock_click, mock_wait, mock_path, mock_fetch): - # 拦截器 + work_list 都 miss → exit 3,不再误报 ok(2026-07-17 xiaobei 事故根因之二) - mock_click.return_value = True - mock_wait.return_value = True - mock_eval.side_effect = ["intercepted", None, "[]"] - mock_fetch.return_value = (None, None) - with self.assertRaises(SystemExit) as ctx: - publish_douyin.cmd_publish(session="s1") - self.assertEqual(ctx.exception.code, 3) - - @mock.patch("publish_douyin.camoufox_click_button_by_text") - @mock.patch("publish_douyin.camoufox_eval") - def test_publish_button_not_found_exits_1(self, mock_eval, mock_click): - mock_click.return_value = False - with self.assertRaises(SystemExit) as ctx: - publish_douyin.cmd_publish(session="s1") - self.assertEqual(ctx.exception.code, 1) - - -class TestCmdGetLink(unittest.TestCase): - @mock.patch("publish_douyin.camoufox_open") - @mock.patch("publish_douyin.camoufox_eval") - def test_get_link_from_localStorage(self, mock_eval, mock_open): - # 策略1: _read_captured_aweme_id 命中 localStorage → 不重开页面 - mock_eval.return_value = "123456" - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_get_link(session="s1") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - self.assertEqual(result["url"], "https://www.douyin.com/video/123456") - self.assertEqual(result["aweme_id"], "123456") - mock_open.assert_not_called() - - @mock.patch("publish_douyin._fetch_newest_aweme_id") - @mock.patch("publish_douyin.camoufox_open") - @mock.patch("publish_douyin.camoufox_eval") - def test_get_link_fallback_manage_dom(self, mock_eval, mock_open, mock_fetch): - # 策略1(localStorage) miss → 策略2(work_list) miss → 策略3 管理页 DOM 命中 - mock_fetch.return_value = (None, None) - mock_eval.side_effect = [None, "https://creator.douyin.com/creator-micro/content/manage", "https://www.douyin.com/video/789"] - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_get_link(session="s1") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - self.assertEqual(result["url"], "https://www.douyin.com/video/789") - mock_open.assert_not_called() # 已在 manage 页,不重开 - - @mock.patch("publish_douyin._fetch_newest_aweme_id") - @mock.patch("publish_douyin.camoufox_open") - @mock.patch("publish_douyin.camoufox_eval") - def test_get_link_work_list_strategy(self, mock_eval, mock_open, mock_fetch): - # 策略1 miss → 策略2 work_list 命中 - mock_eval.return_value = None # _read_captured_aweme_id miss - mock_fetch.return_value = ("999", "标题") - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_get_link(session="s1") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - self.assertEqual(result["url"], "https://www.douyin.com/video/999") - self.assertEqual(result["aweme_id"], "999") - mock_open.assert_not_called() - - @mock.patch("publish_douyin._fetch_newest_aweme_id") - @mock.patch("publish_douyin.camoufox_open") - @mock.patch("publish_douyin.camoufox_eval") - def test_get_link_no_result_returns_ok_url_none(self, mock_eval, mock_open, mock_fetch): - # 三条策略都 miss → 不 exit,返回 ok=True url=None(发布已成功) - mock_fetch.return_value = (None, None) - mock_eval.side_effect = [None, "https://creator.douyin.com/creator-micro/content/manage", "null"] - out = StringIO() - with mock.patch("sys.stdout", out): - publish_douyin.cmd_get_link(session="s1") - result = json.loads(out.getvalue()) - self.assertTrue(result["ok"]) - self.assertIsNone(result["url"]) - - -class TestCmdRun(unittest.TestCase): - """run 命令不再自管探活——假设 login-manager 已就位,直接走 upload → fill → publish → get-link。""" - - @mock.patch("publish_douyin.cmd_get_link") - @mock.patch("publish_douyin.cmd_publish") - @mock.patch("publish_douyin.cmd_fill") - @mock.patch("publish_douyin.cmd_upload") - def test_run_invokes_chain_in_order(self, mock_upload, mock_fill, mock_publish, mock_get_link): - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "v.mp4" - video.write_bytes(b"x") - publish_douyin.cmd_run(video=str(video), title="t", caption="c") - mock_upload.assert_called_once() - mock_fill.assert_called_once() - mock_publish.assert_called_once() - mock_get_link.assert_called_once() - - -class TestIntegrationDryRun(unittest.TestCase): - """CLI smoke test: --help 应该可执行。""" - - def test_help_runs(self): - result = subprocess.run( - [sys.executable, str(SCRIPTS_DIR / "publish_douyin.py"), "--help"], - capture_output=True, text=True, timeout=10, check=False, - ) - self.assertEqual(result.returncode, 0) - self.assertIn("upload", result.stdout) - - -if __name__ == "__main__": - unittest.main() diff --git a/crews/main/skills/expert-bd/SKILL.md b/crews/main/skills/expert-bd/SKILL.md new file mode 100644 index 00000000..9b1d19fd --- /dev/null +++ b/crews/main/skills/expert-bd/SKILL.md @@ -0,0 +1,61 @@ +--- +name: expert-bd +description: 商务拓展(BD)专家。承接找客户、评论区拓展(截流)、商业情报采集、竞争对手/重点客户动向监控、每日简报等完整商务拓展工作,也覆盖推特与小红书的互动操作(点赞/转推/收藏/关注/评论)、闲鱼商品搜索与私信等配套操作。用户只需要说目标和给素材,具体流程和判定标准由专家自己把握。零散的记录、查询、采集、互动等操作也可以直接做。不涉及投资人关系(找投资人/融资跟进/项目申报走 expert-ir)。 +metadata: + openclaw: + emoji: 💼 +--- + +# 商务拓展(BD)专家 + +## 预设 Workflow + +整活直接走对应 workflow: + +| 场景 | Workflow | 什么时候触发 | +|------|----------|-------------| +| 潜在客户探索 | Lead Hunting | 按关键词搜索平台内容,策略 A 分析发布者画像 / 策略 B 评论区挖掘潜客,去重记录,可选触达 | +| 评论区拓展(“截流”式获客) | Comment Engagement | 按关键词搜索内容后进评论区留言 / 回复 / 私信,做获客或品宣 | +| 信息搜集/竞对监控/每日简报 | Intel Gathering | 监控指定信源(自媒体账号 / 网页),按预设标准提取商业情报,生成简报 / 报告 / 监控表格 | +| 竞争对手 / 重点客户动向监控 | Competitor Watch | 以对象为中心的动向监控:多信源采集 → 动向识别分级 → 重大动向告警 + 定期简报 | + +## 执行方式与定时任务 + +所有 workflow 默认按**一次性任务**执行。**仅当用户明确希望周期性执行**时,才落为定时任务:写入模板、启用 / 停用流程与心跳批跑约束见包内 `scheduling.md`。不要主动建议或预填定时任务。 + +## 资源命名约定 + +- Tools、Workflows、`scheduling.md` 等名称是 `expert-bd` 技能包内的逻辑资源名,不是 Agent Workspace 路径,也不要拼成相对路径执行。 +- 技能部署后整个包通过软链进入运行环境;Agent 不要假设这些资源被展开到 Workspace 下。 +- 其他文档中出现的 `db/` 才是 Workspace 相对路径,统一从 Workspace 根目录解析。 +- 只有工具清单中明确列出的 wrapper 名称可以直接作为 shell 命令调用;其余 Tool 名称仅用于定位对应说明。 + +零散操作(只想采个 RSS、只想搜个闲鱼商品、只想点赞关注)直接用下面的工具。 + +## 工具清单 + +零散活儿直接调用,不走完整 workflow。按工具名称查找对应说明,不要把工具名拼成路径。 + +| 工具 | 用途 | 命令 | +|------|------|------| +| `bd-record` | BD 线索 / 互动记录数据库(创作者探索 + 帖子互动去重) | `bd-record` | +| `info-record` | 情报条目数据库(采集去重 + 按日查询) | `info-record` | +| `rss-reader` | 发现并抓取网页 RSS/Atom feed | `rss-reader` | +| `xianyu-ops` | 闲鱼商品搜索 / 详情 / 私信 | `xianyu-ops` | +| `twitter-interact` | Twitter/X 点赞 / 转推 / 收藏 / 关注(回复属 `twitter-post` 发布范畴,在 `expert-twitter` 包内) | `twitter-interact` | +| `xhs-interact` | 小红书评论 / 回复 / 点赞 / 关注(纯浏览器指导,agent 按说明直接驱动 camoufox-cli) | 无 | + +跨领域通用技能:`smart-search`(构造各平台搜索 URL)、`browser-guide`(浏览器操作规范)、`email-ops`(邮件发送)。 + +## 数据与记录 + +- BD 数据层只有两个库,都在 Workspace `db/` 下:`db/bd_record.db`(线索与互动)、`db/info_record.db`(情报条目),使用前如果数据库文件不存在,先调对应工具 `init-db`(幂等)初始化。 +- 去重检查(`check-*`)必须在打开详情页 / 执行互动之前做。 +- 定时批跑(仅用户启用后)只按用户已配置的策略执行并写入记录,不修改用户已建档的条目、不主动发起配置外的接触(完整约束见 `scheduling.md`)。 +- 未有明确约定的文件、产出物以及中间产物,统一存放在 Workspace 根目录下的 `bd/` 文件夹下,不要散落在 Workspace 下。 + +## 边界 + +- 找投资人 / 融资材料 / 投资人跟进 → `expert-ir`。 +- 项目申报 / 补贴 / 创业大赛 → `expert-ir` 专家包(Project Application Workflow)。 +- X/Twitter 起号、定位、发帖编排走 `expert-twitter`;本包只承担其互动与获客场景。 diff --git a/crews/main/skills/expert-bd/scheduling.md b/crews/main/skills/expert-bd/scheduling.md new file mode 100644 index 00000000..f2564212 --- /dev/null +++ b/crews/main/skills/expert-bd/scheduling.md @@ -0,0 +1,142 @@ +# 定时执行(可选) + +> 本文件是 `expert-bd` 专家包附属说明:BD 各 workflow 如何落为定时任务。 + +**原则**:`expert-bd` 的所有 workflow 默认按**一次性任务**执行。**仅当用户明确希望周期性执行**(如每天一次、每周一次)时,才将配置写入 workspace 的 `HEARTBEAT.md` 并配 cron。不要主动建议或预填定时任务。 + +## 启用流程 + +1. 先按对应 workflow 跑通一次,与用户确认全部配置要素(平台 / 关键词 / 策略 / 信源 / 提取标准 / 交付形式)。 +2. 参照下方模板,把**用户实际启用的模式**写入 `HEARTBEAT.md`(不要预填未启用的模式;多模式并存时按顺序排列,模式之间用 `---` 分隔)。 +3. spawn IT engineer 按段内执行时间 / 频率配置 cron。 +4. 在 `MEMORY.md`「已启用的定时任务」段登记任务名、cron 表达式与启用日期。 + +## 停用流程 + +从 `HEARTBEAT.md` 删除对应配置段落,spawn IT engineer 移除对应 cron,并从 `MEMORY.md` 登记段移除。 + +--- + +## HEARTBEAT.md 写入模板 + +### Lead Hunting(潜在客户探索) + +```markdown +### Lead Hunting(潜在客户探索) + +**状态**:已启用 + +**搜集策略**: + +**目标平台**: +- xhs:<关键词1>、<关键词2> +- dy:<关键词1>、<关键词2> +- web:<站点URL>:<搜索关键词> + +**潜在客户判定标准**: +- 策略 A(发布者画像匹配): + - 符合特征: + - <特征描述1> + - 排除特征(同行/竞对): + - <特征描述1> +- 策略 B(评论区潜客挖掘): + - 纳入评论特征: + - <特征描述1> + - 排除评论特征: + - <特征描述1> + +**执行参数**: +- 频率:<每天N次 / 每N小时> +- 每次最大探索量: +- 反馈形式:<列表报告 / Cold Touch 私信 / Email 联系>(策略 B 及 xhs 仅支持列表报告) +- Cold Touch 话术:<话术内容> +- Email 话术:<话术内容> + +**执行**:按 `expert-bd` 的 Lead Hunting Workflow 执行 +``` + +### Comment Engagement(评论区拓展) + +> ⚠️ 小红书不支持批量自动化(走 `xhs-interact` 严格控制频次)。 + +```markdown +### Comment Engagement(评论区拓展) + +**状态**:已启用 + +**目标平台**: +- dy:<关键词1> +- fb:<关键词1> + +**互动策略**: + +**互动话术**: +- <话术内容> + +**执行参数**: +- 频率:<描述> + +**执行**:按 `expert-bd` 的 Comment Engagement Workflow 执行 +``` + +### Intel Gathering(商业情报采集) + +```markdown +### Intel Gathering(商业情报采集) + +**状态**:已启用 + +**监控信源**: +- xhs - <账号名/ID>:<监控说明> +- <网站URL>:<监控说明> + +**提取标准**: +- <要提取的信息描述> + +**交付形式**:<简报 / 报告 / 监控表格> + +**执行时间**: + +**执行**:按 `expert-bd` 的 Intel Gathering Workflow 执行 +``` + +### Competitor Watch(竞争对手 / 重点客户动向监控) + +```markdown +### Competitor Watch(动向监控) + +**状态**:已启用 + +**监控对象**: +- 竞品 - <对象名>:<信源列表> +- 客户 - <对象名>:<信源列表> + +**动向提取标准**: +- <什么算动向> + +**交付形式**:<重大动向即时告警 + 定期简报 / 仅简报 / 仅监控表格> + +**执行时间**: + +**执行**:按 `expert-bd` 的 Competitor Watch Workflow 执行 +``` + +--- + +## 定时执行约束(心跳批跑时) + +- **只在心跳里批跑用户已配置的策略**,不发起配置外的接触;不主动帮用户发起 BD 接触(用户说"现在要联系 X 客户"才执行)。 +- **不修改 `bd-record` / `info-record` 中用户已建档的条目**(推进 / 标记 passed 由用户白天决定)。 +- **凌晨不扫码登录**:cookie 失效 → 跳过该平台,记入 `EXPIRED_PLATFORMS`,白天提醒用户重登。 +- 各模式仍受平台风控与工具自身频率限制约束(见各工具说明)。 + +## 汇报格式(心跳总报告加一段) + +``` +## BD 巡检 +- Lead Hunting:扫了 X 个新内容,发现 Y 个潜在客户(已写入 bd-record) +- Comment Engagement:对 Z 个帖子互动(已写入 bd-record) +- Intel Gathering:采集 W 条情报(已写入 info-record) +- Competitor Watch:识别 V 条动向,重大 M 条(已告警 / 已写入 info-record) +(无新内容的模式跳过) +``` diff --git a/crews/main/skills/bd-record/SKILL.md b/crews/main/skills/expert-bd/tools/bd-record/SKILL.md similarity index 61% rename from crews/main/skills/bd-record/SKILL.md rename to crews/main/skills/expert-bd/tools/bd-record/SKILL.md index 57e301b3..74ed79b9 100644 --- a/crews/main/skills/bd-record/SKILL.md +++ b/crews/main/skills/expert-bd/tools/bd-record/SKILL.md @@ -1,19 +1,19 @@ --- name: bd-record -description: 当执行 BD(商务拓展)任务时维护 SQLite 追踪数据库,记录已探索的创作者(模式一)和已互动的帖子(模式二),避免重复追踪和重复互动。 +description: BD 线索与互动记录数据库(SQLite):已探索创作者去重、已互动帖子去重。 --- -# BD Record 技能 +# bd-record — BD 记录数据库工具 -在 `./db/bd_record.db` 中维护持久化 SQLite 数据库,供 lead-hunting(模式一)和 comment-engagement(模式二)使用。 +在 Workspace `db/bd_record.db` 中维护持久化 SQLite 数据库,供 Lead Hunting(创作者探索)与 Comment Engagement(帖子互动)去重使用。 ## 数据库位置 ``` -./db/bd_record.db +/db/bd_record.db ``` -初始化(幂等,可重复执行):`./skills/bd-record/scripts/init-db.sh` +所有命令通过 PATH wrapper `bd-record <子命令>` 调用,不要拼接脚本路径。初始化(幂等,可重复执行):`bd-record init-db`。 --- @@ -48,27 +48,19 @@ description: 当执行 BD(商务拓展)任务时维护 SQLite 追踪数据 --- -## 脚本命令 +## 命令 -所有脚本均需在 workspace 根目录下执行。 - -### 初始化数据库 - -```bash -./skills/bd-record/scripts/init-db.sh -``` - -### 模式一:创作者记录 +### 创作者记录(Lead Hunting 用) **检查创作者是否已记录**: ```bash -./skills/bd-record/scripts/check-creator.sh --platform <平台> --creator-id <创作者ID> +bd-record check-creator --platform <平台> --creator-id <创作者ID> ``` 返回 JSON:`{"exists": true/false}` **记录创作者**: ```bash -./skills/bd-record/scripts/record-creator.sh \ +bd-record record-creator \ --platform <平台> \ --creator-id <创作者ID> \ --nickname <昵称> \ @@ -78,17 +70,17 @@ description: 当执行 BD(商务拓展)任务时维护 SQLite 追踪数据 ``` 返回 JSON:`{"ok": true, "id": <记录ID>}` 或 `{"ok": false, "error": "..."}` -### 模式二:帖子互动记录 +### 帖子互动记录(Comment Engagement 用) **检查帖子是否已互动**: ```bash -./skills/bd-record/scripts/check-post.sh --platform <平台> --post-url <帖子URL> +bd-record check-post --platform <平台> --post-url <帖子URL> ``` 返回 JSON:`{"exists": true/false, "replied": true/false}` **记录互动**: ```bash -./skills/bd-record/scripts/record-post.sh \ +bd-record record-post \ --platform <平台> \ --post-title <标题> \ --post-url <帖子URL> \ @@ -102,7 +94,7 @@ description: 当执行 BD(商务拓展)任务时维护 SQLite 追踪数据 ## 使用规则 -1. **模式一**:打开创作者主页前先用 `check-creator.sh` 判断是否已记录;如果已在记录中则跳过。读取创作者信息后,不管是否符合标准,都要用 `record-creator.sh` 记录。 -2. **模式二**: - - 直接回帖策略:打开帖子前先用 `check-post.sh` 判断是否已操作过,已操作则跳过;回复后用 `record-post.sh` 记录。 - - reply/dm 策略:互动前先判断是否对同一内容/发布者已 touch 过,已 touch 则跳过;touch 后用 `record-post.sh` 记录。 +1. **创作者探索**:打开创作者主页前先用 `check-creator` 判断是否已记录;已在记录中则跳过。读取创作者信息后,不管是否符合标准,都要用 `record-creator` 记录。 +2. **帖子互动**: + - 直接回帖策略:打开帖子前先用 `check-post` 判断是否已操作过,已操作则跳过;回复后用 `record-post` 记录。 + - reply/dm 策略:互动前先判断是否对同一内容/发布者已 touch 过(查 `reply_target_id`),已 touch 则跳过;touch 后用 `record-post` 记录。 diff --git a/crews/main/skills/expert-bd/tools/bd-record/bd-record.sh b/crews/main/skills/expert-bd/tools/bd-record/bd-record.sh new file mode 100755 index 00000000..5d9e2726 --- /dev/null +++ b/crews/main/skills/expert-bd/tools/bd-record/bd-record.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# bd-record.sh — bd-record 工具 wrapper(子命令分发) +# 让 agent 用 `bd-record <子命令> [参数...]` 走 PATH,零路径拼接。 +# 每个子命令 exec 转发到 scripts/ 下对应脚本,不改语义。 +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +# 只解析 ~/.openclaw/bin 软链一层(bin/ -> /skills/expert-bd/tools/bd-record/bd-record.sh), +# 不 readlink -f 继续展开 workspace 内 skills/expert-bd 指向仓库模板的软链。 +# 子脚本用 dirname $0 向上推导 workspace 根(db/…),readlink -f 会把 +# workspace 折叠成仓库模板路径,导致 ROOT 解析到模板目录(无 db → 空结果)。 +# 保留字面 workspace 路径,ROOT 才能命中真实运行数据目录。 +if [ -L "$SELF" ]; then + _target="$(readlink "$SELF")" + case "$_target" in + /*) SELF="$_target" ;; + *) SELF="$(cd "$(dirname "$SELF")" && pwd)/$_target" ;; + esac +fi +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" + +cmd="${1:-}" +if [ $# -gt 0 ]; then shift; fi + +case "$cmd" in + init-db) exec bash "$SCRIPT_DIR/scripts/init-db.sh" "$@" ;; + check-creator) exec bash "$SCRIPT_DIR/scripts/check-creator.sh" "$@" ;; + record-creator) exec bash "$SCRIPT_DIR/scripts/record-creator.sh" "$@" ;; + check-post) exec bash "$SCRIPT_DIR/scripts/check-post.sh" "$@" ;; + record-post) exec bash "$SCRIPT_DIR/scripts/record-post.sh" "$@" ;; + *) + cat >&2 <<'USAGE' +用法: bd-record <子命令> [参数...] + +子命令: + init-db 初始化 db/bd_record.db(幂等) + check-creator 创作者去重检查(--platform --creator-id) + record-creator 记录创作者(--platform --creator-id --nickname --homepage-url --qualified --notes) + check-post 帖子互动去重检查(--platform --post-url) + record-post 记录互动(--platform --post-title --post-url --strategy --reply-content [--reply-target-id]) +USAGE + exit 1 + ;; +esac diff --git a/crews/main/skills/bd-record/scripts/check-creator.sh b/crews/main/skills/expert-bd/tools/bd-record/scripts/check-creator.sh similarity index 94% rename from crews/main/skills/bd-record/scripts/check-creator.sh rename to crews/main/skills/expert-bd/tools/bd-record/scripts/check-creator.sh index 1cacd1ca..3a397064 100755 --- a/crews/main/skills/bd-record/scripts/check-creator.sh +++ b/crews/main/skills/expert-bd/tools/bd-record/scripts/check-creator.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_FILE="$WORKSPACE_DIR/db/bd_record.db" PLATFORM="" diff --git a/crews/main/skills/bd-record/scripts/check-post.sh b/crews/main/skills/expert-bd/tools/bd-record/scripts/check-post.sh similarity index 95% rename from crews/main/skills/bd-record/scripts/check-post.sh rename to crews/main/skills/expert-bd/tools/bd-record/scripts/check-post.sh index 3e603e15..2ba69bc4 100755 --- a/crews/main/skills/bd-record/scripts/check-post.sh +++ b/crews/main/skills/expert-bd/tools/bd-record/scripts/check-post.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_FILE="$WORKSPACE_DIR/db/bd_record.db" PLATFORM="" diff --git a/crews/main/skills/bd-record/scripts/init-db.sh b/crews/main/skills/expert-bd/tools/bd-record/scripts/init-db.sh similarity index 94% rename from crews/main/skills/bd-record/scripts/init-db.sh rename to crews/main/skills/expert-bd/tools/bd-record/scripts/init-db.sh index ae8e4f76..dd13e76b 100755 --- a/crews/main/skills/bd-record/scripts/init-db.sh +++ b/crews/main/skills/expert-bd/tools/bd-record/scripts/init-db.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_DIR="$WORKSPACE_DIR/db" DB_FILE="$DB_DIR/bd_record.db" diff --git a/crews/main/skills/bd-record/scripts/record-creator.sh b/crews/main/skills/expert-bd/tools/bd-record/scripts/record-creator.sh similarity index 96% rename from crews/main/skills/bd-record/scripts/record-creator.sh rename to crews/main/skills/expert-bd/tools/bd-record/scripts/record-creator.sh index 42cdfefc..1cfc9a11 100755 --- a/crews/main/skills/bd-record/scripts/record-creator.sh +++ b/crews/main/skills/expert-bd/tools/bd-record/scripts/record-creator.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_FILE="$WORKSPACE_DIR/db/bd_record.db" PLATFORM="" diff --git a/crews/main/skills/bd-record/scripts/record-post.sh b/crews/main/skills/expert-bd/tools/bd-record/scripts/record-post.sh similarity index 96% rename from crews/main/skills/bd-record/scripts/record-post.sh rename to crews/main/skills/expert-bd/tools/bd-record/scripts/record-post.sh index 612e7655..dfb4ef7b 100755 --- a/crews/main/skills/bd-record/scripts/record-post.sh +++ b/crews/main/skills/expert-bd/tools/bd-record/scripts/record-post.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_FILE="$WORKSPACE_DIR/db/bd_record.db" PLATFORM="" diff --git a/crews/main/skills/info-record/SKILL.md b/crews/main/skills/expert-bd/tools/info-record/SKILL.md similarity index 53% rename from crews/main/skills/info-record/SKILL.md rename to crews/main/skills/expert-bd/tools/info-record/SKILL.md index b50a5e17..24b88143 100644 --- a/crews/main/skills/info-record/SKILL.md +++ b/crews/main/skills/expert-bd/tools/info-record/SKILL.md @@ -1,19 +1,19 @@ --- name: info-record -description: 当执行 BD(商务拓展)任务时维护 SQLite 情报采集数据库,记录已采集的信息内容,避免重复采集,支持按日查询已采集情报。 +description: BD 情报采集数据库(SQLite):已采集内容去重、按日查询情报条目。 --- -# Info Record 技能 +# info-record — 情报记录数据库工具 -在 `./db/info_record.db` 中维护持久化 SQLite 数据库,供 intel-gathering(模式三)使用。 +在 Workspace `db/info_record.db` 中维护持久化 SQLite 数据库,供 Intel Gathering(商业情报采集)使用。 ## 数据库位置 ``` -./db/info_record.db +/db/info_record.db ``` -初始化(幂等,可重复执行):`./skills/info-record/scripts/init-db.sh` +所有命令通过 PATH wrapper `info-record <子命令>` 调用,不要拼接脚本路径。初始化(幂等,可重复执行):`info-record init-db`。 --- @@ -34,27 +34,19 @@ description: 当执行 BD(商务拓展)任务时维护 SQLite 情报采集 --- -## 脚本命令 - -所有脚本均需在 workspace 根目录下执行。 - -### 初始化数据库 - -```bash -./skills/info-record/scripts/init-db.sh -``` +## 命令 ### 检查内容是否已采集 ```bash -./skills/info-record/scripts/check-content.sh --source <信源URL或标识> +info-record check-content --source <信源URL或标识> ``` 返回 JSON:`{"exists": true/false}` ### 记录采集内容 ```bash -./skills/info-record/scripts/record-content.sh \ +info-record record-content \ --source <信源URL或标识> \ --source-type <信源类型> \ --title <标题> \ @@ -67,7 +59,7 @@ description: 当执行 BD(商务拓展)任务时维护 SQLite 情报采集 ### 查询今日采集 ```bash -./skills/info-record/scripts/query-today.sh +info-record query-today ``` 返回今日采集的所有记录(JSON 数组格式)。 @@ -75,6 +67,6 @@ description: 当执行 BD(商务拓展)任务时维护 SQLite 情报采集 ## 使用规则 -1. 打开帖子/视频详情前,先用 `check-content.sh` 判断该内容是否已记录;已记录则跳过。 -2. 每个内容采集完成后,立即用 `record-content.sh` 将采集结果记录入库。 -3. 执行完毕后,用 `query-today.sh` 读取当日所有采集信息,按与用户约定的交付形式生成交付物。 +1. 打开帖子/视频详情前,先用 `check-content` 判断该内容是否已记录;已记录则跳过。 +2. 每个内容采集完成后,立即用 `record-content` 将采集结果记录入库。 +3. 执行完毕后,用 `query-today` 读取当日所有采集信息,按与用户约定的交付形式生成交付物。 diff --git a/crews/main/skills/expert-bd/tools/info-record/info-record.sh b/crews/main/skills/expert-bd/tools/info-record/info-record.sh new file mode 100755 index 00000000..f39d8476 --- /dev/null +++ b/crews/main/skills/expert-bd/tools/info-record/info-record.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# info-record.sh — info-record 工具 wrapper(子命令分发) +# 让 agent 用 `info-record <子命令> [参数...]` 走 PATH,零路径拼接。 +# 每个子命令 exec 转发到 scripts/ 下对应脚本,不改语义。 +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +# 只解析 ~/.openclaw/bin 软链一层,不 readlink -f 继续展开 workspace 内 +# skills/expert-bd 指向仓库模板的软链。子脚本用 dirname $0 向上推导 +# workspace 根(db/…),必须保留字面 workspace 路径才能命中运行数据目录。 +if [ -L "$SELF" ]; then + _target="$(readlink "$SELF")" + case "$_target" in + /*) SELF="$_target" ;; + *) SELF="$(cd "$(dirname "$SELF")" && pwd)/$_target" ;; + esac +fi +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" + +cmd="${1:-}" +if [ $# -gt 0 ]; then shift; fi + +case "$cmd" in + init-db) exec bash "$SCRIPT_DIR/scripts/init-db.sh" "$@" ;; + check-content) exec bash "$SCRIPT_DIR/scripts/check-content.sh" "$@" ;; + record-content) exec bash "$SCRIPT_DIR/scripts/record-content.sh" "$@" ;; + query-today) exec bash "$SCRIPT_DIR/scripts/query-today.sh" "$@" ;; + *) + cat >&2 <<'USAGE' +用法: info-record <子命令> [参数...] + +子命令: + init-db 初始化 db/info_record.db(幂等) + check-content 内容去重检查(--source) + record-content 记录采集内容(--source --source-type --title --author --publish-date --content) + query-today 查询今日采集的全部记录(JSON 数组) +USAGE + exit 1 + ;; +esac diff --git a/crews/main/skills/info-record/scripts/check-content.sh b/crews/main/skills/expert-bd/tools/info-record/scripts/check-content.sh similarity index 93% rename from crews/main/skills/info-record/scripts/check-content.sh rename to crews/main/skills/expert-bd/tools/info-record/scripts/check-content.sh index 4ede756f..0b0a5fd4 100755 --- a/crews/main/skills/info-record/scripts/check-content.sh +++ b/crews/main/skills/expert-bd/tools/info-record/scripts/check-content.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_FILE="$WORKSPACE_DIR/db/info_record.db" SOURCE="" diff --git a/crews/main/skills/info-record/scripts/init-db.sh b/crews/main/skills/expert-bd/tools/info-record/scripts/init-db.sh similarity index 91% rename from crews/main/skills/info-record/scripts/init-db.sh rename to crews/main/skills/expert-bd/tools/info-record/scripts/init-db.sh index b69d258f..75eabf95 100755 --- a/crews/main/skills/info-record/scripts/init-db.sh +++ b/crews/main/skills/expert-bd/tools/info-record/scripts/init-db.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_DIR="$WORKSPACE_DIR/db" DB_FILE="$DB_DIR/info_record.db" diff --git a/crews/main/skills/info-record/scripts/query-today.sh b/crews/main/skills/expert-bd/tools/info-record/scripts/query-today.sh similarity index 88% rename from crews/main/skills/info-record/scripts/query-today.sh rename to crews/main/skills/expert-bd/tools/info-record/scripts/query-today.sh index abb05178..03cd0d05 100755 --- a/crews/main/skills/info-record/scripts/query-today.sh +++ b/crews/main/skills/expert-bd/tools/info-record/scripts/query-today.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_FILE="$WORKSPACE_DIR/db/info_record.db" if [[ ! -f "$DB_FILE" ]]; then diff --git a/crews/main/skills/info-record/scripts/record-content.sh b/crews/main/skills/expert-bd/tools/info-record/scripts/record-content.sh similarity index 96% rename from crews/main/skills/info-record/scripts/record-content.sh rename to crews/main/skills/expert-bd/tools/info-record/scripts/record-content.sh index 37e0aaa2..cf16172b 100755 --- a/crews/main/skills/info-record/scripts/record-content.sh +++ b/crews/main/skills/expert-bd/tools/info-record/scripts/record-content.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" DB_FILE="$WORKSPACE_DIR/db/info_record.db" SOURCE="" diff --git a/crews/main/skills/expert-bd/tools/rss-reader/SKILL.md b/crews/main/skills/expert-bd/tools/rss-reader/SKILL.md new file mode 100644 index 00000000..53017172 --- /dev/null +++ b/crews/main/skills/expert-bd/tools/rss-reader/SKILL.md @@ -0,0 +1,72 @@ +--- +name: rss-reader +description: 发现网站的 RSS/Atom feed URL,然后抓取并解析 feed 中的文章。 +metadata: + openclaw: + emoji: "📡" + requires: + bins: + - node +--- + +# rss-reader — 工具说明 + +> 本文是 `expert-bd` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 + +发现网站的 RSS/Atom feed URL,抓取并解析 feed 中的文章。适合监控网站更新、批量采集同一信源的多篇文章(无需逐页访问)。 + +**输入**:feed URL(未知时先按下方「发现 feed URL」找到);可选 `--limit` / `--skip`。 +**输出**:markdown,分两段——全文文章(可直接处理,无需访问原文 URL)与摘要链接(需访问原文取全文)。 + +## 调用方式 + +通过 PATH 调用 wrapper,无需拼接脚本路径: + +```bash +rss-reader [--limit N] [--skip url1,url2,...] +``` + +| Option | Description | +|--------|-------------| +| `--limit N` | Max entries to return (default: 20) | +| `--skip url1,url2,...` | Skip entries whose URLs are already processed (deduplication) | + +输出两段的处理: + +- **Full-content articles**(正文 >200 字符):直接提取 title / author / date / content,**无需访问文章 URL**。 +- **Summary-only links**(仅短摘要):逐条访问 URL 获取全文(浏览器或 web_fetch)。 + +--- + +## 发现 feed URL + +已有 RSS/Atom URL 时跳过本节。 + +**方法 A — 页面源码**:导航到网站 snapshot,找 `` 里的 ``: + +```html + + +``` + +**方法 B — 常见路径**(逐个试,直到返回 XML): + +``` +/feed /feed.xml /rss /rss.xml /atom.xml /index.xml +/?feed=rss2 /feeds/posts/default +``` + +**方法 C** — 找页面上的 RSS 图标 🟠 或 "RSS" / "Subscribe" / "Feed" 链接。 + +有效 feed URL 返回以 ` **Reply / Quote** 不在本 skill(属于 `twitter-post` 的 Quote Tweet / Reply to Tweet 流程)。 -> -> 本 skill 与 login-manager **完全无关**——Twitter 互动是纯浏览器操作,走持久化 session `twitter`(与 `twitter-post` 共用同一个 session),登录态在 session profile 里闭环,**不导出 cookie/UA 落中央存储**。探活 + 登录流程在本 skill 自管,见下方「探活与登录」段。 +> 本文是 `expert-bd` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 ---- +在 Twitter/X 上执行互动操作:点赞 / 取消点赞 / 转推 / 取消转推 / 收藏 / 取消收藏 / 关注 / 取关。 -## 适用场景 +**输入**:推文 URL(`https://x.com//status/`)或用户 handle(`@xxx` / 主页 URL)+ 动作类型。 +**输出**:`{ok, tweet_id|user, action, session}` JSON;已处于目标态时输出 `note: 已...` + exit 0。 -- 用户:"帮我给这条推点赞" -- 用户:"转推一下这个" -- 用户:"关注 @xxx" -- BD 场景:监控 mentions → 智能回复 + 互动 -- 内容运营:批量收藏 / 点赞目标内容 +> Reply / Quote 属于 `twitter-post` 的发布范畴,不在本工具能力范围内。 +> +> 本工具与 login-manager **完全无关**——互动是纯浏览器操作,走持久化 session `twitter`(与 `twitter-post` 共用同一个 session),登录态在 session profile 里闭环,**不导出 cookie/UA 落中央存储**。 --- -## 8 个子命令 +## 9 个子命令 | 子命令 | 目标 | 频率限制 | |--------|------|----------------| @@ -48,9 +45,9 @@ metadata: ## 前置条件 -### 1. 探活与登录(本 skill 自管,不走 login-manager) +### 1. 探活与登录(本工具自管,不走 login-manager) -走持久化 session `twitter`(与 `twitter-post` 共用同一个 session 名 `twitter`,靠 session 名字符串约定共享同一 profile 目录与登录态——任一技能登录后另一个不需重登)。探活方式:开 session open 平台首页 + snapshot 看是否跳登录页。 +走持久化 session `twitter`(与 `twitter-post` 共用同一个 session 名 `twitter`,靠 session 名字符串约定共享同一 profile 目录与登录态——任一工具登录后另一个不需重登)。探活方式:开 session open 平台首页 + snapshot 看是否跳登录页。 `run` 子命令在脚本内自动探活(`_check_session_alive`);单条子命令(`like` / `retweet` / ...)不内嵌探活,调用方(agent)按下方流程先探活再调单条。 @@ -64,17 +61,17 @@ camoufox-cli --session twitter --json snapshot # → 跳到登录页 / 出现登录按钮 = 登录态失效,走重登 ``` -重登流程(失效时)——登录流程按 `browser-guide` skill 走有头手动登录(手机号+验证码 / Twitter APP 扫码),登录后**close session**——登录态落磁盘 profile,不留进程占内存。本 skill 做互动操作 + `twitter-post` 做发布操作时用 `--session twitter --persistent` 重起无头即恢复,用完再 close。只在 session 卡死时由调用方手动 `camoufox-cli --session twitter --json close` teardown。 +重登流程(失效时)——登录流程按 `browser-guide` skill 走有头手动登录(手机号+验证码 / Twitter APP 扫码),登录后**close session**——登录态落磁盘 profile,不留进程占内存。互动操作用 `--session twitter --persistent` 重起无头即恢复,用完再 close。只在 session 卡死时由调用方手动 `camoufox-cli --session twitter --json close` teardown。 ```bash # X 登录风控对无头 + QR 识别严格,有头人工登录最稳 camoufox-cli --session twitter --persistent --headed --json open "https://x.com/login" # 告知用户「**Twitter/X** 浏览器已打开,请在窗口里手动完成登录(账号密码 / 手机 APP 扫码),完成后告诉我」 # 等用户回复后 snapshot 验登录态就位 -# 登录就位后 close session——登录态落磁盘 profile,本 skill + twitter-post 按需重起无头复用 +# 登录就位后 close session——登录态落磁盘 profile,按需重起无头复用 ``` -**不导出 cookie/UA**——登录态只在 session profile 里闭环,不落 `~/.openclaw/logins/`。本 skill 不调用 `cookies export` / `identity export`。 +**不导出 cookie/UA**——登录态只在 session profile 里闭环,不落 `~/.openclaw/logins/`。本工具不调用 `cookies export` / `identity export`。 ### 2. 频率跟踪文件(首次自动创建) @@ -84,33 +81,33 @@ camoufox-cli --session twitter --persistent --headed --json open "https://x.com/ 所有互动操作共享同一个 `--persistent` session `twitter`(指纹冻结 + cookie 留 profile)。并发调用由 forked cli 的 **fail-first 队列**串行拒绝——脚本不自动排队、不自动等待,读到 `session twitter 正忙` 文本时 exit 3,调用方(agent)应等待当前操作完成后再试。 -**与 `twitter-post` 共 session**:两个技能都用 `--session twitter`,所以共享同一 profile 目录与登录态——twitter-post 登录后 twitter-interact 不需重登,反之亦然。靠 session 名字符串约定即可,无需别的机制。 +**与 `twitter-post` 共 session**:两者都用 `--session twitter`,共享同一 profile 目录与登录态——一方登录后另一方不需重登。靠 session 名字符串约定即可,无需别的机制。 --- -## 使用方式 +## 调用方式 ### 单条操作 ```bash # 点赞 -twitter_interact like https://x.com/username/status/1234567890 +twitter-interact like https://x.com/username/status/1234567890 # 转推 -twitter_interact retweet https://x.com/username/status/1234567890 +twitter-interact retweet https://x.com/username/status/1234567890 # 关注 -twitter_interact follow @openai +twitter-interact follow @openai # 或 -twitter_interact follow https://x.com/openai +twitter-interact follow https://x.com/openai ``` ### 一键跑 ```bash # 一键:login 探活 → 操作 -twitter_interact run --tweet-url --action -twitter_interact run --user --action +twitter-interact run --tweet-url --action +twitter-interact run --user --action ``` ### 并发约束(fail-first,不并行) @@ -123,9 +120,9 @@ twitter_interact run --user --action --- -## 工作流程 +## 脚本实现要点 -> **实现要点**:脚本 `twitter_interact.py` 内置三个模式,agent 无需手写 eval: +> `twitter_interact.py` 内置三个模式,agent 无需手写 eval: > 1. **article-scoped 探针**:按 tweet_id 定位含 `a[href*="/status/"]` 的 article,按钮查找限定其内——会话页有多 article,bare `querySelector('[data-testid="like"]')` 会抓第一个(父推)误操作。 > 2. **testid 确认菜单**:retweet→`[data-testid="retweetConfirm"]`、unretweet→`unretweetConfirm`、unfollow→`confirmationSheetConfirm`,比 text match 稳且不受本地化影响。 > 3. **晚水合轮询**:Python 侧 20×500ms 找按钮 / article,确认菜单 20×250ms。 @@ -143,7 +140,7 @@ twitter_interact run --user --action ├─ like 在 → _click_scoped(tid,"like") → _poll_probe(tid,["unlike"]) 验翻转 → record + 输出 └─ 10s 内都没找到 → exit 1(DOM 未加载或未登录) 4. check_freq_limit(操作前已校验)→ 通过则 record_action -5. close session(登录态在磁盘 profile,下次 / twitter-post 按需重起无头复用) +5. close session(登录态在磁盘 profile,下次按需重起无头复用) 6. 输出 {ok, tweet_id, action, session} ``` @@ -205,6 +202,8 @@ twitter_interact run --user --action } ``` +**风控告警阈值**:日累计 50% 上限时输出 warning(不是 hard block)。 + --- ## 错误处理 @@ -256,22 +255,13 @@ twitter_interact run --user --action ### pitfall: X UI 改版 → testid 失效 - **症状**:`[data-testid="like"]` / `retweetConfirm` 等找不到 -- **workaround**:本 skill 的 testid 积植自 OpenCLI `clis/twitter/`(实战维护中),比公开推测稳;仍需部署后真机验证(见 `docs/post-deploy-verification.md`)。main agent 看到 exit 1 时**应**触发 selector 检查 - ---- - -## 相关 skill - -- `twitter-post`(Quote / Reply / Long post 在那边,用 forked cli `upload` 命令传媒体) -- `twitter-post` 共用 session `twitter`(靠 session 名约定共享登录态,无需别的机制) +- **workaround**:本工具的 testid 积淀自 OpenCLI `clis/twitter/`(实战维护中),比公开推测稳;仍需部署后真机验证。看到 exit 1 时应触发 selector 检查 --- ## Notes -- **Reply / Quote 流程在 twitter-post**(typed publish 是"发布"范畴,不在本 skill) -- **发布频率与互动频率分开追踪**(不互相影响) -- **不**与 published-track 共享频率统计(本 skill 自有 FREQ_TRACKER_PATH) -- **BD 场景主推**:关注目标用户(follow)+ 点赞目标推(like)+ 收藏(bookmark)— 这三个是 BD 自动化常用组合 -- **风控告警阈值**:日累计 50% 上限时输出 warning(不是 hard block) -- **forked cli 新命令**:`upload`(本 skill 不用,无媒体)/ fail-first 队列(本 skill 依赖,串行化并发)——本 skill 不导出 cookie/UA,故不用 `identity export` +- 发布频率与互动频率分开追踪,互不影响 +- 不与 published-track 共享频率统计(本工具自有 FREQ_TRACKER_PATH) +- BD 常用组合:关注目标用户(follow)+ 点赞目标推(like)+ 收藏(bookmark) +- 不导出 cookie/UA,不用 `identity export` diff --git a/crews/main/skills/twitter-interact/scripts/twitter_interact.py b/crews/main/skills/expert-bd/tools/twitter-interact/scripts/twitter_interact.py similarity index 99% rename from crews/main/skills/twitter-interact/scripts/twitter_interact.py rename to crews/main/skills/expert-bd/tools/twitter-interact/scripts/twitter_interact.py index 5134f097..6d2287b4 100755 --- a/crews/main/skills/twitter-interact/scripts/twitter_interact.py +++ b/crews/main/skills/expert-bd/tools/twitter-interact/scripts/twitter_interact.py @@ -167,7 +167,7 @@ def twitter_session(): """单一持久化 session `twitter` 的生命周期(单一 session)。 - 正常退出 / 一般错误:**close** session——登录态在磁盘 profile,不留进程占内存; - 下次操作(本 skill / twitter-post)按需重起无头 session,profile 桥接登录态。 + 下次操作(本工具 / twitter-post)按需重起无头 session,profile 桥接登录态。 - SessionBusyError:**不 close**(close 会 tear down 正在跑的另一个操作),透传 exit 3 """ session = TWITTER_SESSION diff --git a/crews/main/skills/twitter-interact/scripts/twitter_interact.sh b/crews/main/skills/expert-bd/tools/twitter-interact/scripts/twitter_interact.sh similarity index 100% rename from crews/main/skills/twitter-interact/scripts/twitter_interact.sh rename to crews/main/skills/expert-bd/tools/twitter-interact/scripts/twitter_interact.sh diff --git a/crews/main/skills/twitter-interact/twitter-interact.sh b/crews/main/skills/expert-bd/tools/twitter-interact/twitter-interact.sh similarity index 100% rename from crews/main/skills/twitter-interact/twitter-interact.sh rename to crews/main/skills/expert-bd/tools/twitter-interact/twitter-interact.sh diff --git a/crews/main/skills/xhs-interact/SKILL.md b/crews/main/skills/expert-bd/tools/xhs-interact/SKILL.md similarity index 75% rename from crews/main/skills/xhs-interact/SKILL.md rename to crews/main/skills/expert-bd/tools/xhs-interact/SKILL.md index 6b0a8e0f..28ff6880 100644 --- a/crews/main/skills/xhs-interact/SKILL.md +++ b/crews/main/skills/expert-bd/tools/xhs-interact/SKILL.md @@ -1,6 +1,6 @@ --- name: xhs-interact -description: 小红书社交互动技能。发表评论、回复评论、点赞、关注。当用户要求评论、回复、点赞或关注小红书用户时触发。 +description: 小红书社交互动——发表评论、回复评论、点赞、取消点赞、关注、取关。camoufox-cli 纯浏览器操作,复用 `xhs-browse` 持久化 session。 metadata: openclaw: emoji: 💬 @@ -9,11 +9,18 @@ metadata: - camoufox-cli --- -# 小红书社交互动 +# xhs-interact — 工具说明 -通过 **camoufox-cli** 完成(纯浏览器操作技能)——**复用 `xhs-browse` 持久化 session**(消费者域 `www.xiaohongshu.com`,与 `xhs-content-ops` / `viral-chaser` / `published-track` 共用)。同一 session 一个且只有一个持久化实例,fail-first 队列:同 session 已有命令在跑时新命令直接 fail,浏览器操作 skill 串行排队。 +> 本文是 `expert-bd` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 -**关键边界**:本技能是**纯 camoufox-cli 浏览器操作技能**,登录态直接复用 `xhs-browse` 持久化 session(登录态 + 指纹冻结在 session profile 里)——**不开独立临时 session、不 import cookie**。每次登录后导出的 cookie + UA 是给**其他脚本类技能**(`xhs-content-ops` / `viral-chaser` / `published-track` 等)做 raw HTTP 抓取用的,**本技能自身不消费 cookie 文件**。 +在小红书上执行互动操作:发表评论、回复评论、点赞 / 取消点赞、关注 / 取关。 + +**输入**:笔记 URL(含 `feed_id` + `xsec_token`)或用户主页(`user_id`)+ 操作内容(评论 / 回复文本等)。 +**输出**:操作结果(以 snapshot 验证状态变化为准)。 + +通过 **camoufox-cli** 完成(纯浏览器操作)——**复用 `xhs-browse` 持久化 session**(消费者域 `www.xiaohongshu.com`,与 `xhs-content-ops` / `viral-chaser` / `published-track` 共用)。同一 session 一个且只有一个持久化实例,fail-first 队列:同 session 已有命令在跑时新命令直接 fail,浏览器操作串行排队。 + +**关键边界**:登录态直接复用 `xhs-browse` 持久化 session(登录态 + 指纹冻结在 session profile 里)——**不开独立临时 session、不 import cookie**。登录后导出的 cookie + UA 是给**其他脚本类技能**(`xhs-content-ops` / `viral-chaser` / `published-track` 等)做 raw HTTP 抓取用的,**本工具自身不消费 cookie 文件**。 --- @@ -27,20 +34,20 @@ camoufox-cli --session xhs-browse --persistent --headed --json open "https://www login-manager --platform xhs-browse ``` -login-manager 一条命令闭环导出+验证+落中央存储(供其他脚本类技能消费,非本技能自用)+ close session。 +login-manager 一条命令闭环导出+验证+落中央存储(供其他脚本类技能消费,非本工具自用)+ close session。 --- -## 互动流程:直接复用 xhs-browse 持久化 session +## session 使用约定 -互动操作**直接在 `xhs-browse` 持久化 session 上跑**——不开独立 session、不 import cookie(camoufox-cli 浏览器方案严禁 `cookies import` 造会话)。下文所有 `camoufox-cli` 命令统一用 `--session xhs-browse --persistent`,若该 session 正被其他浏览器操作 skill 占用(fail-first 拒绝 → 命令报 session 正忙),等其完成再串行接力,**不要**自动 close 正在跑的 session。 +互动操作**直接在 `xhs-browse` 持久化 session 上跑**——不开独立 session、不 import cookie(camoufox-cli 浏览器方案严禁 `cookies import` 造会话)。下文所有 `camoufox-cli` 命令统一用 `--session xhs-browse --persistent`,若该 session 正被其他浏览器操作占用(fail-first 拒绝 → 命令报 session 正忙),等其完成再串行接力,**不要**自动 close 正在跑的 session。 ```bash # 全文下方 $SESSION 一律指 xhs-browse 持久化 session SESSION="xhs-browse" ``` -任务结束后**close 该 session**——持久化 session 登录态在磁盘 profile,不留进程占内存;后续自己 / 其他浏览器操作技能用 `--session <平台 key> --persistent` 重起无头即恢复。互动过程中任何时候发现登录已失效则走 login-manager 有头重登流。 +任务结束后**close 该 session**——持久化 session 登录态在磁盘 profile,不留进程占内存;后续用 `--session xhs-browse --persistent` 重起无头即恢复。互动过程中任何时候发现登录已失效则走 login-manager 有头重登流。 --- @@ -63,6 +70,12 @@ camoufox 拿当前 URL: camoufox-cli --session "$SESSION" --json eval "window.location.href" ``` +## Feed 详情页 URL 格式 + +``` +https://www.xiaohongshu.com/explore/{feed_id}?xsec_token={xsec_token}&xsec_source=pc_feed +``` + --- ## 必做约束 @@ -72,17 +85,9 @@ camoufox-cli --session "$SESSION" --json eval "window.location.href" --- -## Feed 详情页 URL 格式 - -``` -https://www.xiaohongshu.com/explore/{feed_id}?xsec_token={xsec_token}&xsec_source=pc_feed -``` - ---- - -## 工作流程(camoufox-cli 版本) +## 操作流程(camoufox-cli 版本) -> **模式说明**:以下每条操作都用 `camoufox-cli` 的 `snapshot` / `eval` / `click` / `type` 子命令实现,**统一在 `xhs-browse` 持久化 session 上跑**(`$SESSION` = `xhs-browse`,见上文「互动流程」段,不开独立 session、不 import cookie)。 +> 每条操作都用 `camoufox-cli` 的 `snapshot` / `eval` / `click` / `type` 子命令实现,**统一在 `xhs-browse` 持久化 session 上跑**(`$SESSION` = `xhs-browse`,不开独立 session、不 import cookie)。 > > 找不到元素时**不要**盲试:先 `snapshot` 看 DOM 真实结构,再决定 selector 改写。 @@ -196,9 +201,9 @@ https://www.xiaohongshu.com/explore/{feed_id}?xsec_token={xsec_token}&xsec_sourc ### pitfall: session_busy_fail_first -- **触发**:`xhs-browse` 持久化 session 正被其他浏览器操作技能(`xhs-content-ops` 等)占用,新命令撞 fail-first 队列 +- **触发**:`xhs-browse` 持久化 session 正被其他浏览器操作(`xhs-content-ops` 等)占用,新命令撞 fail-first 队列 - **症状**:命令报「session xhs-browse 正忙」/ 类似 SessionBusy 错误 -- **workaround**:这是**预期行为**(原则 1 + fail-first 队列)。等当前占用方完成再串行接力,**不要**自动 close 正在跑的 session(close 会 tear down 别人的操作)。 +- **workaround**:这是**预期行为**(单一持久化 session + fail-first 队列)。等当前占用方完成再串行接力,**不要**自动 close 正在跑的 session(close 会 tear down 别人的操作)。 ### pitfall: cookie_expired_during_interaction @@ -215,6 +220,6 @@ https://www.xiaohongshu.com/explore/{feed_id}?xsec_token={xsec_token}&xsec_sourc | 页面出现登录墙 | 同上重走 login-manager 登录流 | | 点赞状态未变化 | 重试一次,仍未变化则报告错误 | | camoufox click/eval 失败 / 超时 | 改用 `eval` 走 JS 方式(最稳);再失败 → 等 60s 后在同一 session 上重试(不开新 session、不 import cookie) | -| `xhs-browse` session 正忙(fail-first 拒绝) | 这是预期行为(原则 1),等当前占用方完成再串行接力,不自动 close 正在跑的 session | +| `xhs-browse` session 正忙(fail-first 拒绝) | 这是预期行为,等当前占用方完成再串行接力,不自动 close 正在跑的 session | | xsec_token 缺失/无效 | 从搜索结果链接中重新获取 signed URL,不要手拼 | | 安全限制/访问异常 | 停止操作 60 秒后重试,或换笔记操作 | diff --git a/crews/main/skills/xianyu-ops/SKILL.md b/crews/main/skills/expert-bd/tools/xianyu-ops/SKILL.md similarity index 82% rename from crews/main/skills/xianyu-ops/SKILL.md rename to crews/main/skills/expert-bd/tools/xianyu-ops/SKILL.md index def003af..a1493dee 100644 --- a/crews/main/skills/xianyu-ops/SKILL.md +++ b/crews/main/skills/expert-bd/tools/xianyu-ops/SKILL.md @@ -1,31 +1,36 @@ --- name: xianyu-ops -description: 闲鱼(goofish.com)商品搜索、查看详情、私信会话管理与回复。通过 forked camoufox-cli 挌久化 session xianyu 完成。当用户要求在闲鱼上搜索商品、查看宝贝、读取或回复私信时触发。 +description: 闲鱼(goofish.com)商品搜索、查看详情、私信会话管理与回复。通过 forked camoufox-cli 持久化 session xianyu 完成。 metadata: openclaw: emoji: 🐟 --- -# 闲鱼操作 +# xianyu-ops — 工具说明 + +> 本文是 `expert-bd` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 通过 **camoufox-cli** 持久化 session `xianyu`(一个且只有一个持久化 session,fail-first 队列:同 session 已有命令在跑时新命令直接 fail)在闲鱼(goofish.com)上完成商品搜索、详情查看、私信管理。 +**输入**:搜索关键词(+ 可选价格区间 / 地区筛选)、商品 `item_id`、私信会话标识或消息文本。 +**输出**:搜索结果 / 商品详情 / 私信列表与内容(JSON 或 snapshot 提取结果)。 + > **主力后端 = `target=camoufox`**。下方命令 / 示例只针对 `target=camoufox`。 -> **`target=host` / `target=node`**:只按本 skill 的「流程 + 提示事项」走——何时有头 / 何时无头 / 频率限制 / 错误处理约定是**后端无关**的,照本 skill 执行。不要照搬 `camoufox-cli ...` 命令,用你当前后端自带的浏览器工具语义调用即可。 +> **`target=host` / `target=node`**:只按本工具的「流程 + 提示事项」走——何时有头 / 何时无头 / 频率限制 / 错误处理约定是**后端无关**的,照本工具执行。不要照搬 `camoufox-cli ...` 命令,用你当前后端自带的浏览器工具语义调用即可。 --- ## 前置条件 -1. 持久化 session `xianyu` 已登录(登录态存 session profile 里)。本 skill 与 login-manager **完全无关**——自管探活 + 登录,**不导出 cookie/UA 落中央存储**。xianyu 不在 login-manager 支持的 5 平台之列。 +1. 持久化 session `xianyu` 已登录(登录态存 session profile 里)。本工具与 login-manager **完全无关**——自管探活 + 登录,**不导出 cookie/UA 落中央存储**。xianyu 不在 login-manager 支持的 5 平台之列。 2. 首次使用 / 登录态失效时,走自管**有头手动**登录流: - `camoufox-cli --session xianyu --persistent --headed --viewport 1920x1080 --json open "https://www.goofish.com"` - `--viewport 1920x1080`:camoufox 默认按指纹给移动端窗口比例,二维码看不全;强制桌面 1920×1080 - 告知用户「**闲鱼** 浏览器已打开,请在窗口里手动扫码登录,完成后告诉我」 - 等用户回复后 `snapshot` 零登录态就位 - - 登录后**close session**——登录态落磁盘 profile,不留进程占内存;本 skill 下次 `--session xianyu --persistent` 重起无头即恢复,用完再 close。 + - 登录后**close session**——登录态落磁盘 profile,不留进程占内存;本工具下次 `--session xianyu --persistent` 重起无头即恢复,用完再 close。 -> **不导出 cookie/UA**——登录态只在 session profile 里闭环,不落 `~/.openclaw/logins/`。本 skill 不调用 `cookies export` / `identity export` / `cookies import`。 +> **不导出 cookie/UA**——登录态只在 session profile 里闭环,不落 `~/.openclaw/logins/`。本工具不调用 `cookies export` / `identity export` / `cookies import`。 --- @@ -69,14 +74,14 @@ metadata: > 在已登录 session 的页面里调 goofish 自己的 `mtop.taobao.idlemtopsearch.pc.search`(页面自带 `window.lib.mtop` 签名,无需手搓),价格区间 / 地区交给**服务端**筛 + 分页,而非抓一屏 DOM 本地过滤——更准、更稳、不漏筛。 ``` -python3 //crews/main/skills/xianyu-ops/scripts/xianyu_search.py \ +xianyu-ops search \ --query "iPhone 15" \ [--min-price 1000] [--max-price 5000] \ [--province 广东] [--city 深圳] \ [--limit 30] ``` -脚本内部:open 搜索页加载 mtop lib → 逐页 `camoufox-cli eval` 调 `window.lib.mtop.request` → 解析 `data.resultList` → 输出 JSON。 +wrapper 转发到包内 `scripts/xianyu_search.py`。脚本内部:open 搜索页加载 mtop lib → 逐页 `camoufox-cli eval` 调 `window.lib.mtop.request` → 解析 `data.resultList` → 输出 JSON。 **服务端筛选编码**(脚本内做,agent 不用管): - `--min-price` / `--max-price`(元)→ `propValueStr.searchFilter = "priceRange:,;"`,单边用 0 / 99999999 兜底 diff --git a/crews/main/skills/xianyu-ops/scripts/xianyu_search.py b/crews/main/skills/expert-bd/tools/xianyu-ops/scripts/xianyu_search.py similarity index 100% rename from crews/main/skills/xianyu-ops/scripts/xianyu_search.py rename to crews/main/skills/expert-bd/tools/xianyu-ops/scripts/xianyu_search.py diff --git a/crews/main/skills/expert-bd/tools/xianyu-ops/xianyu-ops.sh b/crews/main/skills/expert-bd/tools/xianyu-ops/xianyu-ops.sh new file mode 100755 index 00000000..99315283 --- /dev/null +++ b/crews/main/skills/expert-bd/tools/xianyu-ops/xianyu-ops.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# xianyu-ops.sh — xianyu-ops 工具 wrapper(子命令分发) +# 让 agent 用 `xianyu-ops <子命令> [参数...]` 走 PATH,零路径拼接。 +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +# 解析 ~/.openclaw/bin 软链一层,定位工具目录(见 bd-record.sh 同款说明)。 +if [ -L "$SELF" ]; then + _target="$(readlink "$SELF")" + case "$_target" in + /*) SELF="$_target" ;; + *) SELF="$(cd "$(dirname "$SELF")" && pwd)/$_target" ;; + esac +fi +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" + +cmd="${1:-}" +if [ $# -gt 0 ]; then shift; fi + +case "$cmd" in + search) exec python3 "$SCRIPT_DIR/scripts/xianyu_search.py" "$@" ;; + *) + cat >&2 <<'USAGE' +用法: xianyu-ops <子命令> [参数...] + +子命令: + search 闲鱼商品搜索(--query [--min-price] [--max-price] [--province] [--city] [--limit]) + +商品详情 / 私信操作走 camoufox-cli + xianyu-ops 工具说明(SKILL.md),无独立脚本。 +USAGE + exit 1 + ;; +esac diff --git a/crews/main/skills/expert-bd/workflows/comment-engagement.md b/crews/main/skills/expert-bd/workflows/comment-engagement.md new file mode 100644 index 00000000..84d4ded7 --- /dev/null +++ b/crews/main/skills/expert-bd/workflows/comment-engagement.md @@ -0,0 +1,136 @@ +# Comment Engagement(评论区拓展) + +通过自媒体平台搜索特定关键词内容,进入内容评论区按预设互动策略进行留言、回复或私信,拓展潜在客户或做品牌宣传(俗称“截流”)。 + +**依赖**:`smart-search`(构造搜索 URL)、`browser-guide`(浏览器操作)、`bd-record`(去重记录)、`xhs-interact`(小红书互动)、`twitter-interact`(Twitter/X 点赞/转推/关注)、`twitter-post`(Twitter/X 回复,收纳在 `expert-twitter` 包内)。 + +> ⚠️ 小红书不支持本 workflow 的批量自动化(风控严格);小红书评论互动走 `xhs-interact` 并严格控制频次。 + +--- + +## 前置条件 + +执行前需确认以下信息(一次性任务与用户对话确认;定时任务从 HEARTBEAT.md 配置读取): +- 目标平台列表及对应的搜索关键词 +- 互动策略(direct_comment / reply_dm / direct_dm) +- 互动话术(用户指定或已确认的自动生成话术) +- 执行频率 + +--- + +## 执行流程 + +### Step 1: 准备工作 + +1. 确保浏览器可用(遵循 `browser-guide`) +2. 初始化 bd-record 数据库(幂等):`bd-record init-db` + +### Step 2: 逐平台搜索 + +对配置的每个平台,按顺序执行: + +1. 使用 `smart-search` 构造该平台的关键词搜索 URL +2. 导航到搜索结果页,等待页面加载完成 +3. 收集搜索结果列表中的内容链接(按由新到旧排序) + - 每次采集数量参考配置,建议不超过 12 个 + +### Step 3: 逐内容互动 + +对每个搜索到的内容,按配置的互动策略执行。通用要求:输入使用 `type` + `slowly: true`,不要用 `fill()`。 + +#### 策略 A:直接留言(direct_comment) + +1. 提取帖子标识(platform, post_url, post_title) + +2. 去重检查: + ```bash + bd-record check-post --platform <平台> --post-url <帖子URL> + ``` + 如果 `{"replied": true}`,则跳过 + +3. 导航到帖子详情页 + +4. 按平台方式发表评论: + - 小红书:使用 `xhs-interact` 的"发表评论"流程 + - 其他平台:找到评论区输入框,输入话术,点击发送 + - 评论内容使用预设的话术 + +5. 等待 1-2 秒确认评论发出 + +6. 记录互动: + ```bash + bd-record record-post \ + --platform <平台> \ + --post-title <标题> \ + --post-url <帖子URL> \ + --strategy direct_comment \ + --reply-content <话术内容> + ``` + +#### 策略 B:寻找特定留言并回复(reply_dm) + +1. 提取帖子标识,做去重检查(同上) + +2. 导航到帖子详情页,等待加载 + +3. 滚动浏览评论区,查找符合特征的留言: + - 如"咨询/询价类留言"、"提问类留言" + - 按预设的留言特征匹配 + +4. 对每条符合特征的留言: + a. 检查是否已回复过该留言(通过 reply_target_id 查 `bd-record`) + b. 如已回复则跳过 + c. 点击回复按钮 + d. 输入个性化回复内容(基于话术模板,结合留言具体内容微调) + e. 点击发送 + f. 记录互动(含 `--reply-target-id` = 留言ID) + +5. 每条回复之间保持 30-60 秒间隔 + +#### 策略 C:寻找特定留言并私信(direct_dm) + +> 注意:此策略风控风险较高,不建议频繁使用。 + +1. 提取帖子标识,做去重检查(同上) + +2. 导航到帖子详情页,等待加载 + +3. 滚动浏览评论区,查找符合特征的留言 + +4. 对每条符合特征的留言: + a. 点击留言发布者头像/昵称进入其主页 + b. 检查是否已对该用户私信过(通过 `bd-record` 查 reply_target_id) + c. 如已私信则跳过 + d. 找到私信/消息入口,发送预设话术 + e. 记录互动(含 `--reply-target-id` = 用户ID) + +5. 每个私信之间保持 60 秒以上间隔 + +### Step 4: 汇总报告 + +1. 统计本批次结果:浏览帖子数、已跳过数(重复)、互动成功数、失败数 +2. 使用 message 工具将汇总报告发送给用户 + +--- + +## 平台特殊处理 + +| 平台 | 互动方式 | 注意事项 | +|------|---------|---------| +| 小红书 | 使用 `xhs-interact` | 每天评论不超过 20 条;评论区可发链接 | +| 抖音 | browser 直接操作 | 评论内容避免包含网址和外链 | +| B站 | browser 直接操作 | 评论区支持链接 | +| 微博 | browser 直接操作 | 评论支持链接和 @ | +| Twitter/X | 回复走 `twitter-post` Reply workflow;点赞/转推/关注走 `twitter-interact` | 公开回复和 DM 均可 | +| Facebook | browser 直接操作 | 公开评论和 Messenger 均可 | + +--- + +## 错误处理 + +| 情况 | 处理 | +|------|------| +| 帖子无法访问(已删除/私密) | 跳过,记录到 bd-record 标记为已处理 | +| 评论区无法加载 | 重试一次,仍失败则跳过该帖子 | +| 评论发送失败(风控/限流) | 停止当前平台操作,记录并继续下一个平台 | +| 浏览器异常 | **不需要重启、不需要报错**!等待 30 秒后在原页面继续操作即可。若仍无法操作,再等 30 秒;若还不行,尝试关闭浏览器后重开;只有关闭重开后仍报错才是真的出错,需停止并反馈用户。 | diff --git a/crews/main/skills/expert-bd/workflows/competitor-watch.md b/crews/main/skills/expert-bd/workflows/competitor-watch.md new file mode 100644 index 00000000..ddb557ca --- /dev/null +++ b/crews/main/skills/expert-bd/workflows/competitor-watch.md @@ -0,0 +1,104 @@ +# Competitor Watch(竞争对手 / 重点客户动向监控) + +围绕指定**对象**(竞争对手、重点客户或其他关注对象)持续采集公开动态,识别动向信号并按重要程度交付告警或简报。 + +与 Intel Gathering 的分工:Intel Gathering 以**信源**为中心(账号 / 网页采什么信息);Competitor Watch 以**对象**为中心——一个对象可能横跨多个信源(社媒账号、官网、博客、招聘页),采集后多一步动向识别与分级。纯信源监控走 Intel Gathering,对象级动向监控走本 workflow。 + +**依赖**:`smart-search`(构造搜索 URL / 发现信源)、`browser-guide`(浏览器操作)、`rss-reader`(网页 RSS 监控)、`wx-mp-hunter`(微信公众号内容获取)、`info-record`(采集记录与去重)。 + +--- + +## 前置条件 + +执行前需确认以下信息(一次性任务与用户对话确认;定时任务从 HEARTBEAT.md 配置读取): + +- 对象列表,每个对象标注类型:`竞品` / `客户` / 其他 +- 对象已知信源(平台账号、官网、博客等);未提供的信源首次执行时由 `smart-search` 发现并补录 +- 动向提取标准:什么算动向(如新品 / 定价调整 / 融资 / 招聘扩张 / 营销活动 / 内容策略变化 / 采购或选型信号) +- 交付形式:重大动向即时告警 + 定期简报(或仅简报 / 仅监控表格) + +--- + +## 执行流程 + +### Step 1: 准备工作 + +1. 确保浏览器可用(遵循 `browser-guide`) +2. 初始化 info-record 数据库(幂等):`info-record init-db` +3. 对缺少已知信源的对象,用 `smart-search` 发现其活跃信源(官方账号、官网、博客),整理为对象-信源对照表交用户确认后使用 + +### Step 2: 逐对象逐信源采集 + +采集机制与 Intel Gathering 相同(去重、记录走 `info-record`),差异只在组织方式——按对象遍历其信源: + +#### 社媒账号信源 + +1. 导航到该账号的内容列表页(主页/作品页) +2. 收集最新内容(仅上次执行后新发布的;无法精确筛选日期则取前 10 条) +3. 对每条内容: + a. 去重检查: + ```bash + info-record check-content --source <内容URL> + ``` + 如果 `{"exists": true}`,跳过 + b. 打开内容详情页,阅读标题、正文/简介(视频只分析简介/描述文字,不下载视频) + c. 记录采集结果,`--content` 中标注对象名与对象类型: + ```bash + info-record record-content \ + --source <内容URL> \ + --source-type <平台标识> \ + --title <标题> \ + --author <对象名> \ + --publish-date <发布日期> \ + --content "[<对象类型>:<对象名>] <提取的关键信息>" + ``` + d. 每条内容之间保持 10-30 秒间隔 + +#### 网页信源 + +1. RSS 支持的网站用 `rss-reader --limit 10`(feed URL 未知时按 `rss-reader` 说明中的发现方法先找到 feed) +2. 不支持 RSS 的网站:browser 导航 → 收集最新内容列表 +3. 对每条内容:去重检查 → 打开详情页 → 记录到 info-record(同样标注对象) + +### Step 3: 动向识别(agent 推理) + +对本批新采集内容逐条判断: + +1. 是否构成动向:对照提取标准,内容反映了对象的**新动作**或**意图信号**才算动向;日常内容、无信息增量的宣传不算。 +2. 动向分类:产品 / 定价 / 融资 / 人事 / 市场活动 / 内容策略 / 采购选型 / 其他。 +3. 重要程度: + - **重大**:直接影响竞争格局或客户决策(如竞品发布对标产品、重点客户出现更换供应商信号、融资到位) + - **常规**:值得记录但不需立刻行动 +4. 证据要求:每条动向必须附原文链接;来源仅为传闻/猜测时标注「传闻/未证实」,不得升级为既成动向。 + +### Step 4: 交付 + +1. **重大动向** → 用 message 工具即时告警,格式:对象 + 动向一句话 + 分类 + 证据链接 + 建议动作(可选)。不等定期汇总。 +2. **常规动向** → 汇总为定期简报 / 监控表格: + + | 对象 | 类型 | 日期 | 动向分类 | 内容摘要 | 重要程度 | 原文链接 | + |------|------|------|----------|----------|----------|----------| + + 简报模式另加一段趋势判断(多个对象在同一方向有动作时点出)。 +3. 无新动向时如实报告「本周期无新动向」,不硬凑内容。 + +--- + +## 错误处理 + +| 情况 | 处理 | +|------|------| +| 对象信源无法访问 | 记录并跳过该信源,下次执行时重试 | +| 内容详情页打不开 | 记录 URL,标注"无法访问"后跳过 | +| RSS feed 不可用 | 降级为 browser 直接访问网页 | +| 网页结构变化(提取失败) | 记录对象、信源和错误,不阻塞其他对象 | +| 浏览器异常 | **不需要重启、不需要报错**!等待 30 秒后在原页面继续操作即可。若仍无法操作,再等 30 秒;若还不行,尝试关闭浏览器后重开;只有关闭重开后仍报错才是真的出错,需停止并反馈用户。 | +| 持续错误 | spawn IT Engineer 协助排查 | + +--- + +## 注意事项 + +- 微信公众号内容使用 `wx-mp-hunter` 技能,不要使用浏览器 +- 与 Intel Gathering 共用 `info-record` 库,靠 `--content` 中的对象标注区分;日常查询用 `info-record query-today`。 +- 定时执行时遵守定时任务约束(凌晨不扫码登录等),见 `scheduling.md`。 diff --git a/crews/main/skills/intel-gathering/SKILL.md b/crews/main/skills/expert-bd/workflows/intel-gathering.md similarity index 56% rename from crews/main/skills/intel-gathering/SKILL.md rename to crews/main/skills/expert-bd/workflows/intel-gathering.md index 2278e226..4fc3f09d 100644 --- a/crews/main/skills/intel-gathering/SKILL.md +++ b/crews/main/skills/expert-bd/workflows/intel-gathering.md @@ -1,19 +1,14 @@ ---- -name: intel-gathering -description: 定时监控特定信源(自媒体账号/网页),按预设标准提取商业情报,生成简报或报告。 ---- - -# Intel Gathering 技能 +# Intel Gathering(商业情报采集) -定时监控特定信源(自媒体账号或网页),按预设提取标准采集商业情报,生成简报/报告交付用户。 +监控特定信源(自媒体账号或网页),按预设提取标准采集商业情报,生成简报/报告交付用户。默认一次性采集;定时监控是用户可选项(见 `scheduling.md`)。 -**依赖技能**:`smart-search`(构造搜索 URL)、`browser-guide`(浏览器操作)、`rss-reader`(网页 RSS 监控)、`info-record`(采集记录与去重) +**依赖**:`smart-search`(构造搜索 URL)、`browser-guide`(浏览器操作)、`rss-reader`(网页 RSS 监控)、`wx-mp-hunter`(微信公众号内容获取)、`info-record`(采集记录与去重)。 --- ## 前置条件 -执行前需确认以下信息: +执行前需确认以下信息(一次性任务与用户对话确认;定时任务从 HEARTBEAT.md 配置读取): - 监控信源列表(平台+账号 或 网页 URL) - 提取标准(要采集什么信息) - 交付形式(简报 / 报告 / 表格) @@ -24,19 +19,15 @@ description: 定时监控特定信源(自媒体账号/网页),按预设标 ### Step 1: 准备工作 -``` -1. 读取 HEARTBEAT.md 获取当前配置(信源列表、提取标准、交付形式) -2. 确保浏览器可用(遵循 browser-guide) -3. 初始化 info-record 数据库(幂等):./skills/info-record/scripts/init-db.sh -``` +1. 确保浏览器可用(遵循 `browser-guide`) +2. 初始化 info-record 数据库(幂等):`info-record init-db` ### Step 2: 逐信源采集 -对 HEARTBEAT.md 中配置的每个信源,按顺序执行: +对配置的每个信源,按顺序执行: #### 自媒体平台账号 -``` 1. 导航到该账号的内容列表页(主页/作品页) 2. 收集最新内容列表(按由新到旧排序): @@ -44,36 +35,41 @@ description: 定时监控特定信源(自媒体账号/网页),按预设标 - 如无法精确筛选日期,则取前 10 条内容 3. 对每条内容: - a. 提取内容标识(source=平台-账号, title, author, publish_date) + a. 提取内容标识(平台, title, author, publish_date) b. 去重检查: - ./skills/info-record/scripts/check-content.sh --source <内容URL> - 如果 {"exists": true},跳过该内容 + ```bash + info-record check-content --source <内容URL> + ``` + 如果 `{"exists": true}`,跳过该内容 c. 打开内容详情页 - d. 按 HEARTBEAT.md 中预设的提取标准采集信息: + d. 按预设的提取标准采集信息: - 阅读内容标题、正文/简介 - 视频内容只需分析视频简介/描述文字,不下载视频 - 提取与标准相关的关键信息 e. 记录采集结果: - ./skills/info-record/scripts/record-content.sh \ - --source <内容URL> \ + ```bash + info-record record-content \ + --source <内容URL or 平台-账号> \ --source-type <平台标识> \ --title <标题> \ --author <作者> \ --publish-date <发布日期> \ --content <提取的关键信息> + ``` f. 每条内容之间保持适当间隔(10-30 秒) -``` #### 网页信源 -``` -1. 对于 RSS 支持的网站:使用 rss-reader 技能获取最新文章 - node ./skills/intel-gathering/scripts/fetch-rss.mjs --limit 10 +1. 对于 RSS 支持的网站:使用 `rss-reader` 获取最新文章: + ```bash + rss-reader --limit 10 + ``` + (feed URL 未知时先按 `rss-reader` 说明中的发现方法找到 feed) 2. 对于不支持 RSS 的网站: a. 使用 browser 导航到网页 @@ -85,15 +81,15 @@ description: 定时监控特定信源(自媒体账号/网页),按预设标 b. 打开内容详情页(browser 或 web_fetch) c. 按提取标准采集信息 d. 记录到 info-record -``` ### Step 3: 生成交付物 -``` 1. 查询当日所有采集信息: - ./skills/info-record/scripts/query-today.sh + ```bash + info-record query-today + ``` -2. 按 HEARTBEAT.md 中预设的交付形式生成交付物: +2. 按预设的交付形式生成交付物: 简报模式: - 每条信息:一句话摘要 + 原文链接 @@ -107,7 +103,6 @@ description: 定时监控特定信源(自媒体账号/网页),按预设标 - Markdown 表格:日期 | 信源 | 标题 | 关键信息 | 原文链接 3. 使用 message 工具将交付物发送给用户 -``` --- @@ -119,7 +114,7 @@ description: 定时监控特定信源(自媒体账号/网页),按预设标 | 内容详情页打不开 | 记录 URL,标注"无法访问"后跳过 | | RSS feed 不可用 | 降级为 browser 直接访问网页 | | 网页结构变化(提取失败) | 记录信源和错误,不阻塞其他信源 | -| 浏览器异常 | **不需要重启、不需要报错**!等待 30 秒后在原页面继续操作即可。若仍无法操作,再等 30 秒;若还不行,尝试关闭浏览器后重开;只有关闭重开后仍报错才是真的出错,需停止并反馈用户。 | +| 浏览器异常 | **不需要重启、不需要报错**!等待 30 秒后在原页面继续操作即可。若仍无法操作,再等 30 秒;若还不行,尝试关闭浏览器后重开;只有关闭重开后仍报错才是真的出错,需停止并反馈用户。 | | 持续错误 | spawn IT Engineer 协助排查 | --- @@ -127,5 +122,5 @@ description: 定时监控特定信源(自媒体账号/网页),按预设标 ## 注意事项 - 视频内容通过视频简介/描述文字分析,不下载视频 -- 微信公众号内容可能需要通过搜狗微信搜索或其他渠道访问 -- 部分平台可能需要登录才能查看完整内容(遵循 browser-guide) +- 微信公众号内容使用 `wx-mp-hunter` 技能,不要使用浏览器 +- 部分平台可能需要登录才能查看完整内容(遵循 `browser-guide`) diff --git a/crews/main/skills/lead-hunting/SKILL.md b/crews/main/skills/expert-bd/workflows/lead-hunting.md similarity index 63% rename from crews/main/skills/lead-hunting/SKILL.md rename to crews/main/skills/expert-bd/workflows/lead-hunting.md index f7e19c2a..c5056b6e 100644 --- a/crews/main/skills/lead-hunting/SKILL.md +++ b/crews/main/skills/expert-bd/workflows/lead-hunting.md @@ -1,20 +1,15 @@ ---- -name: lead-hunting -description: 通过自媒体平台按搜集策略探索潜在客户——策略 A 分析帖子发布者画像,策略 B 从评论区挖掘潜客。 ---- - -# Lead Hunting 技能 +# Lead Hunting(潜在客户探索) 通过自媒体平台搜索特定关键词内容,按搜集策略筛选潜在客户。策略 A 逐一分析创作者主页判定是否为潜在客户;策略 B 扫描帖子评论区,根据评论内容挖掘潜在客户。 -**依赖技能**:`smart-search`(构造搜索 URL)、`browser-guide`(浏览器操作)、`email-ops`(email 操作)、`bd-record`(去重记录) +**依赖**:`smart-search`(构造搜索 URL)、`browser-guide`(浏览器操作)、`wx-mp-hunter`(微信公众号内容获取)、`email-ops`(邮件)、`bd-record`(去重记录)。 --- ## 前置条件 -执行前需确认以下信息: -- 搜集策略(A 发布者画像匹配 / B 评论区潜客挖掘) +执行前需确认以下信息(一次性任务与用户对话确认;定时任务从 HEARTBEAT.md 配置读取): +- 搜集策略(A 发布者画像匹配 / B 评论区潜客挖掘,互斥,不可混用) - 目标平台列表及对应的搜索关键词 - 潜在客户判定标准 / 评论筛选标准 - 每次最大探索量 @@ -26,35 +21,32 @@ description: 通过自媒体平台按搜集策略探索潜在客户——策略 ### Step 1: 准备工作 -``` -1. 读取 HEARTBEAT.md 获取当前配置(搜集策略、平台、关键词、判定标准、最大探索量) -2. 确保浏览器可用(遵循 browser-guide) -3. 初始化 bd-record 数据库(幂等):./skills/bd-record/scripts/init-db.sh -``` +1. 确认当前配置(搜集策略、平台、关键词、判定标准、最大探索量):定时任务从 HEARTBEAT.md 读取;一次性任务用与用户对话确认的配置 +2. 确保浏览器可用(遵循 `browser-guide`) +3. 初始化 bd-record 数据库(幂等):`bd-record init-db` ### Step 2: 逐平台搜索 -对 HEARTBEAT.md 中配置的每个平台,按顺序执行: +对配置的每个平台,按顺序执行: -``` -1. 使用 smart-search 技能构造该平台的关键词搜索 URL -2. 导航到搜索结果页 -3. 等待页面加载完成 -4. 收集搜索结果列表中的内容链接(最多取 HEARTBEAT.md 中配置的最大探索量) +1. 使用 `smart-search` 构造该平台的关键词搜索 URL +2. 导航到搜索结果页,等待页面加载完成 +3. 收集搜索结果列表中的内容链接(最多取配置的最大探索量) - 内容按由新到旧排序(使用平台默认排序) - 提取每个内容的创作者主页链接 -``` +4. 微信公众号内容使用 `wx-mp-hunter` 技能,不要使用浏览器 ### Step 3 (策略 A): 逐创作者判定 对每个搜索到的创作者,按顺序执行: -``` 1. 提取创作者标识信息(平台、creator_id、nickname、homepage_url) 2. 去重检查: - ./skills/bd-record/scripts/check-creator.sh --platform <平台> --creator-id <创作者ID> - 如果 {"exists": true},则跳过该创作者,继续下一个 + ```bash + bd-record check-creator --platform <平台> --creator-id <创作者ID> + ``` + 如果 `{"exists": true}`,则跳过该创作者,继续下一个 3. 导航到创作者主页,等待加载 @@ -64,57 +56,60 @@ description: 通过自媒体平台按搜集策略探索潜在客户——策略 - 对每个作品读取标题、简介/描述文字 - 视频内容只需分析视频简介,不下载视频 -6. 按 HEARTBEAT.md 中预设的判定标准,判断是否符合潜在客户: +6. 按预设的判定标准,判断是否符合潜在客户: - 分析创作者定位、内容方向、商业属性 - 排除同行/竞对(内容与我们相似但非潜在客户) - 判定为潜在客户需给出明确理由 7. 记录到数据库(不管是否符合标准): - ./skills/bd-record/scripts/record-creator.sh \ + ```bash + bd-record record-creator \ --platform <平台> \ --creator-id <创作者ID> \ --nickname <昵称> \ --homepage-url <主页URL> \ --qualified <1或0> \ --notes <判定理由> + ``` 8. 操作间隔:每个创作者之间保持 30-60 秒间隔,避免平台风控 -``` ### Step 3 (策略 B): 逐帖子评论区挖掘 对每个搜索到的帖子,按顺序执行: -``` 1. 提取帖子标识(platform, post_url, post_title) 2. 导航到帖子详情页,等待评论区加载 3. 如果支持按时间排序,切换到按时间排序,确保评论从新到旧排列 -4. 滚动浏览评论区,查找符合 HEARTBEAT.md 中评论筛选标准的评论: +4. 滚动浏览评论区,查找符合评论筛选标准的评论: - 提取评论者信息:昵称、user_id、IP属地、评论内容、评论日期 + - 微信公众号平台不支持评论获取 5. 对每条符合标准的评论: a. 以评论者 user_id 作为 --creator-id 做去重检查: - ./skills/bd-record/scripts/check-creator.sh --platform <平台> --creator-id <评论者user_id> - 如果 {"exists": true},则跳过该评论者 + ```bash + bd-record check-creator --platform <平台> --creator-id <评论者user_id> + ``` + 如果 `{"exists": true}`,则跳过该评论者 b. 记录到数据库: - ./skills/bd-record/scripts/record-creator.sh \ + ```bash + bd-record record-creator \ --platform <平台> \ --creator-id <评论者user_id> \ --nickname <昵称> \ --homepage-url <原贴URL> \ --qualified <1或0> \ --notes <评论内容及判定理由> + ``` 6. 操作间隔:每个帖子之间保持 30-60 秒间隔,避免平台风控 -``` ### Step 4: 汇总报告 -``` 1. 统计本批次结果: - 策略 A:探索总数、符合数、跳过数(已记录) - 策略 B:扫描帖子数、发现潜客数、跳过数(已记录) @@ -123,12 +118,11 @@ description: 通过自媒体平台按搜集策略探索潜在客户——策略 - 策略 A:平台、昵称、ID、主页 URL、判定理由 - 策略 B:平台、昵称、user_id、IP属地、评论内容、评论日期、原贴url -3. 按 HEARTBEAT.md 中配置的反馈形式执行(仅策略 A 支持 Cold Touch 私信 / Email 联系): +3. 按配置的反馈形式执行(仅策略 A 支持 Cold Touch 私信 / Email 联系): - **Cold Touch 私信**:逐一给符合标准的创作者发送预设话术私信,使用各平台的私信/消息功能,每个私信之间保持 30-60 秒间隔 - - **Email 联系**:先校验 `email-ops` 所需环境变量是否齐全,若不全则跳过 Email 步骤并记录;齐全则使用 `email-ops` 发送邮件,每个邮件之间保持 30-60 秒间隔 + - **Email 联系**:先校验 `email-ops` 所需环境变量是否齐全,若不全则跳过 Email 步骤并记录;齐全则使用 `email-ops` 发送邮件,每封邮件之间保持 30-60 秒间隔 4. 使用 message 工具将汇总报告发送给用户 -``` --- @@ -138,6 +132,6 @@ description: 通过自媒体平台按搜集策略探索潜在客户——策略 |------|------| | 平台搜索结果为空 | 记录平台名称,跳过该平台,继续下一个 | | 创作者主页无法访问 | 记录"无法访问"后跳过,不阻塞流程 | -| 浏览器异常 | **不需要重启、不需要报错**!等待 30 秒后在原页面继续操作即可。若仍无法操作,再等 30 秒;若还不行,尝试关闭浏览器后重开;只有关闭重开后仍报错才是真的出错,需停止并反馈用户。 | +| 浏览器异常 | **不需要重启、不需要报错**!等待 30 秒后在原页面继续操作即可。若仍无法操作,再等 30 秒;若还不行,尝试关闭浏览器后重开;只有关闭重开后仍报错才是真的出错,需停止并反馈用户。 | | 平台风控/验证码 | 停止当前平台操作,记录并继续下一个平台 | | 持续错误 | spawn IT Engineer 协助排查,当前任务标记为部分完成 | diff --git a/crews/main/skills/expert-douyin/SKILL.md b/crews/main/skills/expert-douyin/SKILL.md new file mode 100644 index 00000000..c6318ff3 --- /dev/null +++ b/crews/main/skills/expert-douyin/SKILL.md @@ -0,0 +1,67 @@ +--- +name: expert-douyin +description: 抖音短视频运营专家。承接从定位起号、选题脚本、内容制作、发布到数据复盘的完整运营工作。零散的发布、拆解参考视频、取数等操作也可以直接做。 +metadata: + openclaw: + emoji: 🎵 +--- + +# 抖音短视频运营专家 + +## 预设 Workflow + +整活直接走对应 workflow: + +| 场景 | Workflow | 什么时候触发 | +|------|----------|-------------| +| 内容 DNA 管理 | Style DNA | 建 / 更新内容 DNA(样本、偏好、局部借鉴、对标融合),决定样本落到哪个 DNA | +| 内容生产 | Content Production | 做一条 / 做几条抖音视频;输入可以是粗略想法、参考视频(仿照 / 同主题改写)、已有素材或已有脚本 | +| 起号与定位 | Account Setup | 新号起号、定位梳理、内容支柱搭建、老号接手与诊断 | +| 账号对标 | Account Benchmark | 对标账号 / 对标视频分析,并与默认或指定 DNA 逐项比较 | +| 改片与调整 | Editing | 改文案、重剪、换封面、调结构、换风格 | +| 数据复盘 | Review | douyin 全部数据复盘:DNA 评估、用户临时看数据 / 复盘 / 评估 DNA | + +## 资源命名约定 + +- Tools、Workflows、DNA 模板等名称是 `expert-douyin` 技能包内的逻辑资源名,不是 Agent Workspace 路径,也不要拼成相对路径执行。 +- 技能部署后整个包通过软链进入运行环境;Agent 不要假设这些资源被展开到 Workspace 下。 +- 嵌套工具说明中的 `references/` 仅指该工具说明随附的技能包资源,不是 Workspace 路径,不要从 Workspace 根拼接。 +- 其他文档中出现的 `douyin/dna/`、`douyin/ref/`、`douyin/outputs/`、`douyin/calibration/` 才是 Workspace 相对路径,统一从 Workspace 根目录解析。 +- 只有命令清单中明确列出的 wrapper 名称可以直接作为 shell 命令调用;其余 Tool 名称仅用于定位对应说明。 + +零散操作(只想发条视频、只想拆解一条参考视频、只想建个 DNA)直接用下面的工具。 + +## 工具清单 + +零散活儿直接调用,不走完整 workflow。按工具名称查找对应说明,不要把工具名拼成路径。 + +| 工具 | 用途 | 命令 | +|------|------|------| +| `douyin-style-profiler` | 生成单条视频 17 维 DNA report,并聚合 DNA 文档与 DNA template | `douyin-style-profiler` | +| `douyin-comments` | 抓取抖音视频评论(对标分析 / 标签反推用,纯 HTTP 不起浏览器) | `douyin-comments` | +| `douyin-publish` | 成片 → 抖音创作者中心发布(浏览器自动化) | `douyin-publish` | + +跨领域通用技能:`viral-chaser`(抖音 / B站 / 小红书视频下载拆解,DNA 采样与仿写参考的取数主力)、`smart-search`(跨平台搜索,选题调研优先走社交平台,不用通用搜索引擎)、`content-calibrator`(DNA 表现评估)、`published-track`(发布记录与指标库)、`login-manager`(抖音登录态维护)。 + +制作链相关技能(边界见 Content Production Workflow):`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门)、`aigc-video-gen`(AIGC 片段)、`siliconflow-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。从零出脚本、端到端制作一律委托 `content-producer`,main 不代写完整脚本。 + +## 风格与 DNA + +账号内容风格 DNA 存储目录是 `douyin/dna/`。未指定 DNA 时默认使用并更新 `dna-0`。生产前同时读取 DNA 文档与 DNA template;对标分析先建立独立对标 DNA,不默认写入 `dna-0`。DNA 维度框架(17 维,初始版本已确认)位于 `douyin-style-profiler` 的 `references/style-17d-framework.md`。 + +## 数据与记录 + +- 发布记录统一走 `published-track`(入库时传 `--account`;`dna_id` 经作品目录 `dna-meta.json` 自动关联) +- DNA 表现评估的引擎是 `content-calibrator`(触发、基线归一化、趋势、报告结构),数据在 `published-track`;**douyin 的全部复盘工作(heartbeat 按量触发 + 用户临时发起)统一走 Review Workflow**,由它调用上述技能并用抖音归因方法分析。评估报告落 `douyin/dna//evals/`,建议经用户逐条确认后走 Style DNA Workflow 回写 DNA +- 平台级数据(受众画像、对标记录、平台状态)存平台运营文件夹 `douyin/calibration/` +- 平台运营文件夹 `douyin/` 统一存放运营产出物、知识、经验和记录表格(如 `douyin/ref/` 参考材料、`douyin/outputs/` 成片与素材);与 `douyin/dna/`、`douyin/calibration/` 等结构化数据目录分开,不混放 +- 数据是用来指导下一轮改进的,不是为了凑数字——每次复盘必须有明确的下一步动作 + +## 平台速查与硬性红线 + +- **发布限频**:单抖音号每 24h ≤ 5 条;触发风控立即降级,30 分钟内不重试。 +- **串行发布**:`douyin-publish` 同一时间只能有一个发布任务在跑(浏览器 session 竞态)。 +- **AIGC 标注**:AI 生成的内容按平台规则标注,`douyin-publish fill` 已内置自主声明"内容由AI生成"。 +- **简介引流**:视频简介可提及产品与业务,但不放明显引流信息;禁止二维码、联系方式;可引导主动搜索或看主页。 +- **登录态**:浏览器操作一律走 `login-manager` 真实登录后的持久化 session,严禁 `cookies import` 造会话。 +- **数据诚实**:互动数据只来自平台接口、`viral-chaser` 返回或用户提供的线索,不编造;估算值必须标注估算方法,不可得的数据写明"数据不可得"。 diff --git a/crews/main/skills/expert-douyin/tools/douyin-comments/SKILL.md b/crews/main/skills/expert-douyin/tools/douyin-comments/SKILL.md new file mode 100644 index 00000000..c66f43fe --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-comments/SKILL.md @@ -0,0 +1,65 @@ +--- +name: douyin-comments +description: 抓取抖音视频的评论列表(纯 HTTP + cookie + 签名,不起浏览器),输出 JSON 与按点赞排序的 markdown 摘要。 +--- + +# douyin-comments — 工具说明 + +> 本文是 `expert-douyin` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 + +抓取指定抖音视频的评论,供对标分析、起号标签反推、评论动机解读使用。 + +**输入**:视频 `aweme_id` 或视频链接(支持 `v.douyin.com` 短链,自动展开)。 +**输出**:JSON(stdout,含评论全文、点赞数、回复数、用户昵称、IP 属地、日期);`--output` 时额外落一份按点赞降序的 markdown 摘要。 + +登录态复用中央存储导出的 douyin cookie + UA(与 `douyin` 持久化 session 同一登录态),纯 HTTP 请求,不启动浏览器。 + +## 使用方式 + +```bash +# 按 aweme_id 抓(默认前 40 条热度评论) +douyin-comments fetch --aweme-id 7389012345678901234 + +# 按链接抓 + 落摘要文件 +douyin-comments fetch \ + --url "https://www.douyin.com/video/7389012345678901234" \ + --limit 60 \ + --output douyin/ref/dna-0/comments/sample-1.comments.md +``` + +参数说明: + +| 参数 | 说明 | +|------|------| +| `--aweme-id` / `--url` | 二选一;`--url` 支持短链,自动展开解析 | +| `--limit` | 抓取条数上限,1-200(上限是防批量请求触风控),默认 40 | +| `--output` | 可选;写入按点赞降序的 markdown 摘要,路径由调用方指定 | + +返回 JSON 主要字段: + +```json +{ + "ok": true, + "awemeId": "...", + "total": 1234, + "fetched": 40, + "truncated": true, + "comments": [ + {"cid": "...", "text": "...", "likeCount": 89, "replyCount": 3, "userName": "...", "ipLabel": "...", "createTime": "2026-08-01"} + ] +} +``` + +## 必做约束 + +- 只读抓取,不发表、不点赞、不回复任何评论。 +- 单次任务批量抓多条视频评论时逐条串行调用,控制总条数(每条 ≤ `--limit`),避免批量请求触风控。 +- 评论文本是用户原话,分析时按动机归类(喜欢内容价值 / 喜欢人物状态 / 喜欢形式设定 / 提出具体问题 / 非恶意吐槽),不要把评论数直接当内容质量。 + +### Exit codes + +| code | 含义 | 调用方动作 | +|------|------|-----------| +| `0` | 抓取成功(`truncated=true` 表示达到 limit 或分页中断,未抓全) | 继续分析 | +| `1` | 参数错 / 网络错 / 签名不可用(stderr 有原因) | 排查后重试;签名不可用交 IT engineer 配凭证 | +| `2` | `SESSION_EXPIRED`——cookie 缺失或失效 | 走 `login-manager --platform douyin` 有头重登后重试 | diff --git a/crews/main/skills/expert-douyin/tools/douyin-comments/douyin-comments.sh b/crews/main/skills/expert-douyin/tools/douyin-comments/douyin-comments.sh new file mode 100755 index 00000000..42c87933 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-comments/douyin-comments.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# douyin-comments — 抖音评论抓取 wrapper +# 让 agent 用 `douyin-comments ` 走 PATH,零路径拼接。 +# 直调 scripts/fetch_comments.ts(纯 HTTP + login-manager cookie + relay 签名,不起浏览器)。 +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +# Resolve symlink (wrapper is ln -sfn'd into ~/.openclaw/bin) so SCRIPT_DIR points at the real skill dir. +while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" +exec node --experimental-strip-types "$SCRIPT_DIR/scripts/fetch_comments.ts" "$@" diff --git a/crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts b/crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts new file mode 100644 index 00000000..0c0db0b1 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts @@ -0,0 +1,337 @@ +#!/usr/bin/env -S node --experimental-strip-types +/** + * fetch_comments.ts — 抖音视频评论抓取(纯 HTTP + cookie + a_bogus 签名) + * + * 复用 login-manager 中央存储的 douyin cookie + UA(与 douyin 持久化 session 同一 + * 登录态)和 _shared/douyin-web.ts 的签名请求链路(与 published-track fetch-metrics + * 取数同源),不启动浏览器。 + * + * Usage: + * node fetch_comments.ts fetch --aweme-id [--limit 40] [--output path/to/comments.md] + * node fetch_comments.ts fetch --url "https://www.douyin.com/video/" [--limit 40] + * node fetch_comments.ts fetch --url "https://v.douyin.com/xxx" ... # 短链自动展开 + * + * Output: JSON 到 stdout(全部评论按热度序 + 点赞排序字段);--output 时额外落 + * 一份按点赞降序的 markdown 摘要。 + * + * Exit codes: + * 0 成功 + * 1 一般错误(参数 / 网络 / 签名不可用) + * 2 SESSION_EXPIRED — cookie 缺失或失效,调用方走 login-manager 重登 + */ + +import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs" +import { dirname, join } from "path" +import { homedir } from "os" + +// ─── Types ──────────────────────────────────────────────────────────────── + +interface CookieRecord { name: string; value: string; domain?: string } + +interface SessionData { + platform: string + cookies?: CookieRecord[] | string + user_agent?: string + updated_at?: string +} + +interface DouyinUser { + nickname?: string + unique_id?: string + short_id?: string +} + +interface DouyinComment { + cid?: string + text?: string + digg_count?: number + create_time?: number + reply_comment_total?: number + ip_label?: string + user?: DouyinUser + sticker?: { content?: string } // 表情包评论无 text +} + +interface CommentListResponse { + status_code?: number + comments?: DouyinComment[] + cursor?: number + has_more?: number | boolean + total?: number +} + +interface FlatComment { + cid: string + text: string + likeCount: number + replyCount: number + userName: string + ipLabel: string + createTime: string +} + +interface FetchResult { + ok: boolean + awemeId: string + total: number + fetched: number + truncated: boolean + comments: FlatComment[] + error?: string +} + +// ─── Session(与 published-track/scripts/fetch-retro-data.ts 同模式)──────── + +const SESSIONS_DIR = join(homedir(), ".openclaw", "logins") +const DEFAULT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" + +function readSession(platform: string): SessionData | null { + const path = join(SESSIONS_DIR, `${platform}.json`) + if (!existsSync(path)) return null + try { + const raw = JSON.parse(readFileSync(path, "utf-8")) + // camoufox-cli `cookies export` 写的是裸数组,归一化为 {cookies: [...]} + if (Array.isArray(raw)) return { platform, cookies: raw } as SessionData + return raw as SessionData + } catch { + return null + } +} + +function readUserAgent(platform: string): string { + const path = join(SESSIONS_DIR, `${platform}.ua.json`) + if (!existsSync(path)) return DEFAULT_UA + try { + const data = JSON.parse(readFileSync(path, "utf-8")) as { userAgent?: string } + return data.userAgent || DEFAULT_UA + } catch { + return DEFAULT_UA + } +} + +function requireSession(platform: string): SessionData { + const data = readSession(platform) + const empty = !data || !data.cookies || (Array.isArray(data.cookies) && data.cookies.length === 0) + if (empty) { + process.stderr.write(JSON.stringify({ ok: false, error: "SESSION_EXPIRED", platform }) + "\n") + process.exit(2) + } + return data +} + +function parseCookies(raw: CookieRecord[] | string | undefined): Record { + const dict: Record = {} + if (Array.isArray(raw)) { + for (const c of raw) { + if (c && typeof c.name === "string" && typeof c.value === "string") { + dict[c.name] = c.value + } + } + } else if (typeof raw === "string" && raw) { + for (const item of raw.split(";")) { + const trimmed = item.trim() + if (!trimmed || !trimmed.includes("=")) continue + const [k, ...rest] = trimmed.split("=") + dict[k.trim()] = rest.join("=").trim() + } + } + return dict +} + +function cookieHeader(dict: Record): string { + return Object.entries(dict).map(([k, v]) => `${k}=${v}`).join("; ") +} + +// ─── aweme_id 解析 ──────────────────────────────────────────────────────── + +function extractAwemeId(url: string): string | null { + const match = url.match(/\/video\/(\d+)/) + return match ? match[1] : null +} + +async function resolveShortLink(url: string): Promise { + let current = url + for (let hop = 0; hop < 5; hop++) { + const resp = await fetch(current, { + redirect: "manual", + headers: { "User-Agent": DEFAULT_UA }, + signal: AbortSignal.timeout(15_000), + }) + const location = resp.headers.get("location") + if (!location) return current + current = location.startsWith("http") ? location : new URL(location, current).href + if (extractAwemeId(current)) return current + } + return current +} + +async function resolveAwemeId(awemeIdArg: string, urlArg: string): Promise { + if (awemeIdArg) { + if (!/^\d+$/.test(awemeIdArg)) { + throw new Error(`--aweme-id 必须是纯数字: ${awemeIdArg}`) + } + return awemeIdArg + } + if (!urlArg) throw new Error("需要 --aweme-id 或 --url") + let url = urlArg + if (!extractAwemeId(url)) { + url = await resolveShortLink(url) + } + const id = extractAwemeId(url) + if (!id) throw new Error(`无法从 URL 解析 aweme_id: ${urlArg}`) + return id +} + +// ─── 评论抓取 ───────────────────────────────────────────────────────────── + +const COMMENT_URI = "/aweme/v1/web/comment/list/" +const PAGE_SIZE = 20 + +function flatten(item: DouyinComment): FlatComment { + const text = item.text || (item.sticker?.content ? `[表情包] ${item.sticker.content}` : "") + return { + cid: item.cid || "", + text, + likeCount: item.digg_count || 0, + replyCount: item.reply_comment_total || 0, + userName: item.user?.nickname || "", + ipLabel: item.ip_label || "", + createTime: item.create_time ? new Date(item.create_time * 1000).toISOString().slice(0, 10) : "", + } +} + +async function fetchComments(awemeId: string, limit: number): Promise { + const session = requireSession("douyin") + const cookieStr = cookieHeader(parseCookies(session.cookies)) + const ua = readUserAgent("douyin") + const { douyinWebGet } = await import("../../../../_shared/douyin-web.ts") + + const comments: FlatComment[] = [] + let cursor = 0 + let total = 0 + let truncated = false + + while (comments.length < limit) { + const remaining = limit - comments.length + const count = Math.min(PAGE_SIZE, Math.max(remaining, 1)) + let resp: Awaited>> | null = null + + // status_code=8 为间歇鉴权抖动(同 douyin-publish work_list 的已知行为),重试 2 次 + for (let attempt = 0; attempt < 3; attempt++) { + resp = await douyinWebGet( + COMMENT_URI, + { aweme_id: awemeId, cursor, count, item_type: 0 }, + cookieStr, + ua, + ) + if (resp.data?.status_code !== 8) break + await new Promise(r => setTimeout(r, 1000 * (attempt + 1))) + } + + const data = resp?.data + if (!resp?.ok || !data || data.status_code !== 0) { + const code = data?.status_code ?? resp?.status ?? "unknown" + // 登录态失效常见表现为非 0 状态码 + 空评论;首屏即失败按 SESSION_EXPIRED 交重登 + if (comments.length === 0) { + return { ok: false, awemeId, total: 0, fetched: 0, truncated: false, comments: [], error: `SESSION_EXPIRED(comment list status_code=${code})` } + } + truncated = true + break + } + + total = data.total || total + const page = (data.comments || []).map(flatten).filter(c => c.text) + comments.push(...page) + + const hasMore = Boolean(data.has_more) + if (!hasMore || page.length === 0) break + cursor = typeof data.cursor === "number" ? data.cursor : cursor + count + } + + if (comments.length > limit) { + comments.length = limit + truncated = true + } + + return { ok: true, awemeId, total, fetched: comments.length, truncated, comments } +} + +// ─── Markdown 摘要 ──────────────────────────────────────────────────────── + +function markdownDigest(result: FetchResult): string { + const sorted = [...result.comments].sort((a, b) => b.likeCount - a.likeCount) + const lines = [ + `# 抖音评论摘要(aweme_id: ${result.awemeId})`, + "", + `- 抓取时间:${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC`, + `- 评论总数(平台口径):${result.total}`, + `- 本次抓取:${result.fetched} 条${result.truncated ? "(已达 --limit,未抓全)" : ""}`, + "- 排序:点赞降序", + "", + "| # | 点赞 | 回复 | 评论 | 用户 | IP | 日期 |", + "|---:|---:|---:|------|------|------|------|", + ] + sorted.forEach((c, i) => { + const text = c.text.replace(/\|/g, "\\|").replace(/\n/g, " ") + lines.push(`| ${i + 1} | ${c.likeCount} | ${c.replyCount} | ${text} | ${c.userName.replace(/\|/g, "\\|")} | ${c.ipLabel} | ${c.createTime} |`) + }) + lines.push("", "> 读评论动机而不是只数评论数:喜欢内容价值 / 喜欢人物状态 / 喜欢形式设定 / 提出具体问题 / 非恶意吐槽,分别指向不同的可借鉴方向。") + return lines.join("\n") + "\n" +} + +// ─── Main ───────────────────────────────────────────────────────────────── + +async function main(): Promise { + const args = process.argv.slice(2) + const command = args[0] + + if (command !== "fetch") { + process.stderr.write("用法: douyin-comments fetch --aweme-id | --url <视频链接> [--limit 40] [--output comments.md]\n") + process.exit(1) + } + + let awemeIdArg = "" + let urlArg = "" + let limit = 40 + let output = "" + + for (let i = 1; i < args.length; i++) { + if (args[i] === "--aweme-id" && args[i + 1]) awemeIdArg = args[++i] + else if (args[i] === "--url" && args[i + 1]) urlArg = args[++i] + else if (args[i] === "--limit" && args[i + 1]) limit = parseInt(args[++i], 10) + else if (args[i] === "--output" && args[i + 1]) output = args[++i] + } + + if (!Number.isFinite(limit) || limit <= 0 || limit > 200) { + process.stderr.write("--limit 必须是 1-200 的整数(避免批量请求触风控)\n") + process.exit(1) + } + + const awemeId = await resolveAwemeId(awemeIdArg, urlArg) + console.error(` → 抓取评论 aweme_id=${awemeId} limit=${limit}`) + const result = await fetchComments(awemeId, limit) + + if (!result.ok) { + if (result.error?.startsWith("SESSION_EXPIRED")) { + process.stderr.write(JSON.stringify({ ok: false, error: "SESSION_EXPIRED", platform: "douyin" }) + "\n") + process.exit(2) + } + process.stderr.write(`❌ ${result.error}\n`) + process.exit(1) + } + + console.error(` ✓ 抓到 ${result.fetched}/${result.total} 条评论`) + + if (output) { + mkdirSync(dirname(output), { recursive: true }) + writeFileSync(output, markdownDigest(result), "utf-8") + console.error(` ✓ 摘要已写入 ${output}`) + } + + process.stdout.write(JSON.stringify(result, null, 2) + "\n") +} + +main().catch(e => { + process.stderr.write(`❌ ${e}\n`) + process.exit(1) +}) diff --git a/crews/main/skills/expert-douyin/tools/douyin-publish/SKILL.md b/crews/main/skills/expert-douyin/tools/douyin-publish/SKILL.md new file mode 100644 index 00000000..dafd509b --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-publish/SKILL.md @@ -0,0 +1,166 @@ +--- +name: douyin-publish +description: 通过浏览器自动化发布视频到抖音创作者中心。纯浏览器操作方案。 +--- + +# douyin-publish — 工具说明 + +> 本文是 `expert-douyin` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 + +通过 **camoufox-cli** 持久化 session `douyin`(一个且只有一个持久化 session,fail-first 队列:同 session 已有命令在跑时新命令直接 fail)在抖音创作者中心发布视频。 + +**输入**:本地视频文件(mp4 / mov,时长 ≤ 15 分钟,建议 < 100MB)、标题(≤ 30 字)、描述文案(含话题标签)。 +**输出**:发布结果 + 作品链接(`https://www.douyin.com/video/`)。 + +> 纯浏览器操作方案:登录态 + 指纹冻结在持久化 session 的磁盘 profile 里。**严禁** `cookies import` 造登录会话,会触发平台风控。 + +--- + +## 发布前置:open 上传页 + agent 判定登录态(必做) + +抖音 cookie 存在预热机制,直接 `douyin-publish run` 可能因 cookie 未激活而不生效。**每次发布前必须先 open 上传页**(无头 persistent session),由 agent 在此页面根据元素判定登录态,之后再走 `run`。 + +```bash +# 1. open 上传页(无头 persistent session `douyin`) +douyin-publish open-page +# 输出: {"ok": true, "session": "douyin", "url": "...", "hint": "agent 用 camoufox-cli eval/snapshot 判定登录态"} + +# 2. agent 判定登录态 +通过页面元素判定登录态,如用户头像/用户名等。示例: +camoufox-cli --session douyin --persistent --json eval "document.querySelector('头像 selector') ? 'logged_in' : 'not_logged_in'" + +也可以直接截图调用视觉模型判定。 + +# 3a. 判定为已登录 → 走发布 +douyin-publish run --video /path/to/video.mp4 --title "标题" --caption "描述" + +# 3b. 判定为未登录 → 走「登录失效处理」 +``` + +> 脚本内 `_check_logged_in` 已 mute 成 no-op:登录态判定由 agent 在 open 上传页后自行根据页面元素完成,脚本不做检查、不因此退出。 + +--- + +## 登录失效处理 + +判定为未登录、或运行中得到 exit 2 时,走 `login-manager` 重登,复用 `douyin` 持久化 session: + +```bash +camoufox-cli --session douyin --persistent --headed --json open "https://www.douyin.com" +# 告知用户在窗口里手动完成创作者中心登录,确认后: +login-manager --platform douyin +``` + +`login-manager` 一条命令闭环导出+验证+落中央存储+close session。重登后重新走「发布前置」。本工具**没有 `login` 子命令、也没有 `cleanup` 子命令**。 + +--- + +## 使用方式 + +### 一键全流程 + +```bash +douyin-publish run \ + --video /path/to/video.mp4 \ + --title "视频标题" \ + --caption "视频描述 #话题1 #话题2" +``` + +`run` 内部串:upload → fill → publish → get-link。 + +### 分步调用(agent 按需) + +```bash +# 1. 上传视频(返回 session 名,后续步骤用) +douyin-publish upload --video video.mp4 + +# 2. 填标题/描述 + 自主声明 +# fill 命令内部自动完成:填标题 -> 填简介 -> 选自主声明"内容由AI生成" -> 点"确定"按钮 +# 自主声明下拉不存在时不阻断(部分账号/页面无此选项) +douyin-publish fill --session --title "标题" --caption "描述" + +# 3. 点发布(返回发布起始时刻,供 get-link 锁定本次作品) +douyin-publish publish --session + +# 4. 取视频链接 +douyin-publish get-link --session +``` + +### 行为说明 + +- `run` 在 close session 之前就拿到作品链接;`get-link` 锁定本次发布的作品(按发布时间窗口筛最新),链接不可得时按 exit 3 处理。 +- `upload` 会自动清掉「上次未发布的视频」草稿恢复框,给新发布一个干净上传页;旧草稿在场时新视频上传/发布会被带偏。 +- `fill` 内置自主声明"内容由AI生成"选择与确认步骤;声明下拉不存在时不阻断。 +- **aweme_id 未捕获即 `exit 3`,不再误报发布成功。** 排查材料在 `/tmp/dy-publish-debug-.json`。 + +--- + +## 创作者中心 URL + +上传页:`https://creator.douyin.com/creator-micro/content/upload?enter_from=dou_web` + +视频管理页:`https://creator.douyin.com/creator-micro/content/manage`(取链接用) + +--- + +## 必做约束 + +- **用完即 close 持久化 session `douyin`**——登录态 + 指纹冻结在磁盘 profile,不留进程占内存;下次发布 `--session douyin --persistent` 重起无头即恢复。只在 session 卡死时 `camoufox-cli --session douyin --json close` teardown。 +- 同 session 已有命令在跑时,新命令 fail-first(返回 `session douyin 正忙,请等待当前操作完成后再试`)——读到这条文本就等当前操作完成再重试,不要盲试。 +- **严禁 `cookies import`**:不开临时 session 再 import cookie,会触发平台风控。执行过程中任何时候发现登录态已失效,走 `login-manager` 有头重登流。 +- 限频:单抖音号每 24h ≤ 5 条发布;触发风控立即降级,30 分钟内不重试。 + +### Exit codes + +| code | 含义 | 调用方动作 | +|------|------|-----------| +| `0` | 发布成功,aweme_id 已捕获 | 继续发布后的记录流程 | +| `1` | 参数错 / crash / DOM 改版(按钮/input 未找到)/ 上传转码超时 | 排查后重试 | +| `2` | 未登录或登录态失效 | 走 `login-manager --platform douyin` 有头重登后重试 | +| `3` | 发布流程走完但未捕获到 aweme_id——发布可能未真正成功 | **人工到管理页核实是否真有新作品**;把 `/tmp/dy-publish-debug-*.json` 回传给研发定位真实发布 API | + +--- + +## Pitfalls + +### pitfall: douyin_login_required_on_creator_center + +- **触发**:访问 `creator.douyin.com` 未登录态 +- **症状**:页面跳到 `creator.douyin.com/login` 或出现登录弹窗 +- **workaround**:脚本返回 `exit 2`,调用方走 `login-manager` 有头手动重登流。 + +### pitfall: real_name_auth_required + +- **触发**:未实名认证的账号 +- **症状**:创作者中心提示"请先完成实名认证"才能发布 +- **workaround**:用户自己走实名认证流程(脚本帮不上) + +### pitfall: video_too_long_or_wrong_format + +- **触发**:上传非 mp4 / mov 格式,或视频时长超限 +- **症状**:上传后转码失败 / 客户端拒收 +- **workaround**:转 mp4 + 检查时长(抖音支持最长 15 分钟) + +### pitfall: dom_changes_creator_center + +- **触发**:抖音创作者中心前端改版 +- **症状**:selector 找不到(input / button 位置变化) +- **workaround**:部署后真机验证更新 selector + +### pitfall: upload_transcode_timeout + +- **触发**:视频上传后转码超时(大文件 / 网络波动) +- **症状**:轮询标题表单超时,脚本报 `视频上传/转码超时(标题表单未出现)` +- **workaround**:检查视频大小(建议 < 100MB);超时后截图排查是 DOM 改版还是转码慢;确认 DOM 已渲染后可用分步命令(`fill` / `publish`)手动继续 + +### pitfall: ai_declaration_confirm + +- **触发**:选完自主声明"内容由AI生成"后未点"确定"按钮 +- **症状**:声明弹窗卡住,发布按钮被遮挡,无法点发布 +- **workaround**:`fill` 命令已内置点"确定"步骤;分步手动操作时选完声明后必须点"确定" + +### pitfall: rate_limit_after_burst_publish + +- **触发**:短时间内连续发布多条 +- **症状**:平台风控 / 上传被拒 / 提示"操作过于频繁" +- **workaround**:每天 ≤ 5 条;触发后 30 分钟内不重试 diff --git a/crews/main/skills/douyin-publish/douyin-publish.sh b/crews/main/skills/expert-douyin/tools/douyin-publish/douyin-publish.sh similarity index 100% rename from crews/main/skills/douyin-publish/douyin-publish.sh rename to crews/main/skills/expert-douyin/tools/douyin-publish/douyin-publish.sh diff --git a/crews/main/skills/douyin-publish/scripts/publish_douyin.py b/crews/main/skills/expert-douyin/tools/douyin-publish/scripts/publish_douyin.py similarity index 100% rename from crews/main/skills/douyin-publish/scripts/publish_douyin.py rename to crews/main/skills/expert-douyin/tools/douyin-publish/scripts/publish_douyin.py diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/SKILL.md b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/SKILL.md new file mode 100644 index 00000000..418ecaf5 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/SKILL.md @@ -0,0 +1,208 @@ +--- +name: douyin-style-profiler +description: 为单条抖音视频提取 17 维 DNA report,按 DNA ID 聚合历史 report 生成 DNA 文档,并推导完整的 DNA template。 +--- + +# douyin-style-profiler + +抖音短视频的内容风格提取与 DNA 生产工具。输入是**视频的转录文本**(口播转录全文 + 标题/描述/时长/互动线索,由 Agent 先行整理成 `.md` / `.txt`)与封面 / 首帧图;不接受视频文件或视频链接作为输入。 + +## 产物模型 + +```text +单条视频 -> DNA report +同一个 DNA 目录下的全部 report + 权重/focus -> DNA 文档 +DNA 文档 -> DNA template +``` + +- **DNA report**:单条视频的 17 维提取结果。 +- **DNA 文档**:聚合历史 report 后得到的内容与风格规则。 +- **DNA template**:由 DNA 文档推导出的生产模板,供生产时直接执行。 + +## 存储结构 + +DNA 以 DNA ID 为主体存储,一个 DNA 可以持续放入任意数量视频,样本可以来自一个或多个参考账号,甚至来自用户直接的想法。 + +```text +douyin/dna/{dna-id}/ + reports/ + {sample-id}.report.md + covers/ + {sample-id}.{ext} + {dna-id}.dna.md + {dna-id}.template.md +``` + +原始转录文本可以临时来自任何位置(建议 `douyin/ref/{dna-id}/transcripts/`);生成后的 DNA report 必须进入对应 DNA 的 `reports/` 目录。 + +## 职责边界 + +- 输入转录文本支持 `.md` / `.txt`;视频文件、链接不直接作为输入。 +- 统计只作为聚合证据底座,不评分、不替代定性判断。 +- 17 维语义判断由 Agent 回读转录原文(必要时回看视频关键帧)完成;封面 / 首帧维度必须由视觉模型读取本地图片完成。 +- 不输出选题价值判断、合规结论、账号权重或风格评分。 +- 不要求用户确认或登记 INDEX。 + +## Report - 单条提取 + +```bash +douyin-style-profiler report \ + --input path/to/transcript.md \ + --dna-id {dna-id} \ + --sample-id {sample-id} \ + --cover-image path/to/cover.jpg \ + --source-url "https://www.douyin.com/video/..." \ + --duration 89 +``` + +默认输出: + +```text +douyin/dna/{dna-id}/reports/{sample-id}.report.md +``` + +参数说明: + +- `--cover-image`:封面或首帧本地图片(抽帧产物或封面下载图),进入视觉模型分析。 +- `--source-url`:原视频链接,作为报告证据保留;本地素材无链接时省略。 +- `--duration`:视频时长(秒),用于口播密度统计;未知时省略。 + +可配置权重: + +```bash +--weight 3 +``` + +可限制该条只在某些维度参与借鉴: + +```bash +--focus hook +--focus speech-rhythm +``` + +Agent 生成 scaffold 后必须回读转录原文,补齐每个维度的单条结论、原文证据和可复用创作信号;钩子维度必须逐字摘录前 3 秒口播 / 首帧字幕。 +封面 / 首帧维度必须读取 `--cover-image` 指向的本地图片,并通过视觉模型补齐主体、构图、色彩、光线、质感、风格、文字视觉、品牌元素、避免项和 AIGC 复现提示词要素。没有图片时记录"未提供",不得编造。 + +## Build - 聚合 DNA 文档与模板 + +```bash +douyin-style-profiler build --dna-id {dna-id} +``` + +默认读取: + +```text +douyin/dna/{dna-id}/reports/ +``` + +默认输出: + +```text +douyin/dna/{dna-id}/{dna-id}.dna.md +douyin/dna/{dna-id}/{dna-id}.template.md +``` + +也可显式传入一个或多个 report 文件/目录: + +```bash +douyin-style-profiler build \ + --input path/to/reports \ + --dna-id {dna-id} +``` + +Agent 聚合时必须: + +1. 读取全部 DNA report,不能只看统计表。 +2. 按每个 report 的 `weight` 和 `focus` 判断影响范围。 +3. 区分高频共性、高权重偏好、局部借鉴、孤例和例外。 +4. 为每个维度写聚合结论、报告依据和创作规则。 +5. 确保 DNA 文档能够完整推导 template。 + +## DNA Template + +Template 是生产模板,不是概念解释。必须从 DNA 文档的 17 个维度推导,至少包含: + +- 选题角度、受众关系 +- 标题类型、参考标题、话题标签策略、封面 / 首帧风格和封面 AIGC 提示词要素 +- 起、承、转、合、CTA 五个固定语义部分(对应短视频的黄金开场、主体推进、高潮转折、收尾、互动引导) +- 每个部分的本段任务、执行方式和必须做 / 避免项 + +模板整体固定为七个部分:选题、标题(含封面)、起、承、转、合、CTA。固定的是语义结构,不是物理时长占比;任一部分可以对应视频中的一个或多个段落,也可以在特定内容形态下弱化。 + +五个视频部分必须充分吸收 DNA 文档中的维度结论: + +| 部分 | 主要推导来源 | +| --- | --- | +| 起 | 3秒钩子、选题角度、口播节奏、语气与人设感、镜头语言 | +| 承 | 视频结构模式、叙事节奏、口播节奏、用词习惯、专业度体现、镜头语言 | +| 转 | 冲突与张力、叙事节奏、情绪表达(语气与人设感)、口播节奏 | +| 合 | 视频结构模式、签名式标记、语气与人设感、系列化与合集 | +| CTA | 互动设计、选题与受众关联、语气与人设感 | + +## Update - 增量聚合 + +```bash +douyin-style-profiler update \ + --input douyin/dna/{dna-id}/reports/{new-sample}.report.md \ + --dna douyin/dna/{dna-id}/{dna-id}.dna.md \ + --template douyin/dna/{dna-id}/{dna-id}.template.md +``` + +脚本会合并 DNA 文档记录的历史 report 与新 report,重新计算加权统计,并保留 Agent 已完成内容。Agent 仍需重新审视聚合结论,再同步修订 DNA 文档和 template。 + +`--input` 可省略。省略时表示没有新增样本,只基于历史 report、既有 DNA 文档和用户输入做融合;适用于采纳另一个 DNA 文档或 template 中的局部规则。此时仍必须通过 `--user-input` 传入要融合的要求,并由 Agent 转译到具体维度。 + +## 用户输入转译 + +用户输入是参考信息,不是可直接入库的 DNA 规则。 + +```bash +--user-input "开头冲突再前置一点" +``` + +Agent 必须把输入转译到具体维度,例如: + +```text +hook:钩子改为矛盾前置,第一句直接给反差结果 +video-structure:开场压缩到 2 秒内进入冲突画面 +speech-rhythm:开场两句均为短句,不做背景铺垫 +``` + +处理要求: + +1. 在 DNA 文档的"用户输入转译区"记录 affected dimensions、DNA 修改和 template 修改。 +2. 把原话转译成可执行的聚合结论与创作规则。 +3. Template 只写转译后的执行规则,不直接抄用户原话。 +4. 与样本证据冲突时保留冲突说明,由用户选择优先级。 + +## Focus ID + +| ID | 维度 | +|---|---| +| `topic-angle` | 选题角度 | +| `title-style` | 标题与文案 | +| `cover-frame` | 封面与首帧 | +| `hook` | 3秒钩子 | +| `word-habit` | 用词习惯 | +| `speech-rhythm` | 口播节奏 | +| `tone` | 语气与人设感 | +| `shot-language` | 镜头语言 | +| `visual-style` | 画面风格 | +| `sound-design` | 声音与BGM | +| `video-structure` | 视频结构模式 | +| `narrative-rhythm` | 叙事节奏 | +| `conflict-tension` | 冲突与张力 | +| `professionalism` | 专业度体现 | +| `interaction-design` | 互动设计 | +| `signature` | 签名式标记 | +| `series-design` | 系列化与合集 | + +## 统计与分词 + +脚本统计转录文本的句段、标点、人称、口播密度(需 `--duration`)等指标;中文高频信号使用相邻二字组合,仅作为候选线索。Agent 必须回读原文判断它是否真是口头禅或签名式表达。 + +当前不引入 jieba。若后续候选噪声明显,可把 jieba 作为候选词挖掘器加入仓库级依赖,但分词结果不能直接作为 DNA 结论。 + +## 参考资料 + +- `references/style-17d-framework.md`(抖音 17 维 DNA 分析框架,初始版本已确认) diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/douyin-style-profiler.sh b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/douyin-style-profiler.sh new file mode 100755 index 00000000..28b481ee --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/douyin-style-profiler.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +# Resolve symlink (wrapper is ln -sfn'd into ~/.openclaw/bin) so SCRIPT_DIR points at the real skill dir. +while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" +exec python3 "$SCRIPT_DIR/scripts/build_style_profile.py" "$@" diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/style-17d-framework.md b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/style-17d-framework.md new file mode 100644 index 00000000..d90c9592 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/style-17d-framework.md @@ -0,0 +1,295 @@ +# 抖音 17 维 DNA 分析框架 + +> **状态:初始版本(2026-08-27 经用户确认)**。后续如需增删合并维度,必须同步更新 `scripts/build_style_profile.py` 的 `DIMENSION_GROUPS` 与 Focus ID 表,并对已有 DNA 重新聚合。 + +本框架用于分析抖音短视频作品的内容与风格,逐步累积形成抖音内容生产 DNA。与微信公众号 17 维不同,短视频的观测物是**口播转录文本 + 画面 + 声音 + 结构节奏**的组合,因此维度按短视频语境重新组织。分析产物必须是三层: + +1. **DNA report**:单条视频的 17 维提取结果。 +2. **DNA 文档**:聚合同一个 DNA ID 下全部 report 后形成的规则。 +3. **DNA template**:从 DNA 文档推导出的生产模板。 + +```text +单条视频 -> DNA report +多个 DNA report + 权重/focus + 用户输入转译 -> DNA 文档 +DNA 文档 -> DNA template +``` + +样本观测来源: + +- `viral-chaser` 拆解产物:ASR 转录全文与分句时间戳、视频时长、标题/描述/作者、互动计数、抽帧关键帧。 +- 用户提供的文字稿、脚本或口述要点(转成 `.md` / `.txt` 后输入)。 +- 画面维度(镜头语言、画面风格、封面 / 首帧)必须由视觉模型读取视频关键帧或封面图完成,不得凭转录文本想象补齐。 + +## 一、选题与包装(3) + +### 1. 选题角度 + +- 选题类型:痛点教程、失败复盘、公开挑战、案例拆解、反常识观点、清单避坑、答疑、场景实验、系列预告等 +- 选题入口:现象、问题、冲突、数据、热点、挑战还是个人经历 +- 观看理由:陌生人为什么要看完这条;看完能记住账号的哪个身份、冲突、热情或承诺 + +分析时必须回答: + +- 这条内容解决了什么具体问题,或提供了什么具体情绪价值。 +- 选题与目标人群的关联是什么:理解、判断、行动、避坑、身份认同还是情绪共鸣。 + +### 2. 标题与文案 + +- 标题类型:痛点型、数字型、反差型、悬念型、搜索长尾型、身份点名型 +- 标题原文与描述文案原文 +- 话题标签策略:主标签、场景词、痛点词的使用方式与数量 +- 搜索词预埋:标题与描述中的长尾搜索句 + +### 3. 封面与首帧 + +封面 / 首帧来源: + +- `viral-chaser` 抽帧关键帧(首选 `frame_00`)或封面图下载。 +- 用户手动提供本地图片文件。 +- 无法获取时记录"未提供",不得根据转录文本或标题想象补齐。 + +Agent 必须通过视觉模型读取图片,至少提取: + +| 特征 | 分析要求 | +| --- | --- | +| 画面主体与场景 | 主体是什么、在做什么、处于什么场景,主体占画面比例 | +| 构图与画幅 | 竖屏构图、主体位置、视觉动线、留白、前景背景层次 | +| 色彩体系 | 主色、辅色、强调色、饱和度、明度、对比关系 | +| 光线与质感 | 自然光 / 人造光、方向、阴影、颗粒、锐度、材质 | +| 风格与媒介 | 实拍 / 插画 / 录屏 / 图文卡片 / 混剪,具体流派或时代感 | +| 文字视觉与图文关系 | 封面字内容、字体气质、字号层级、贴纸、标题框、字图遮挡关系 | +| 品牌识别元素 | 固定角标、边框、水印、系列符号、版式惯性 | +| 避免项 | 与该封面风格相反、容易破坏识别度的元素 | + +视觉模型分析必须能反推为一套可执行 AIGC 提示词,包含主体、场景、构图、画幅、色彩、光线、质感、风格、文字视觉、品牌元素和负向约束。 + +## 二、钩子与表达(4) + +### 4. 3秒钩子 + +- 钩子类型:矛盾前置、数据冲击、场景代入、悬念留白、结果反差、身份点名、利益承诺 +- 钩子原文:前 3 秒口播逐字摘录 + 首帧字幕 +- 钩子与定位的对应关系;钩子承诺在正文中是否兑现 +- 画面、字幕、口播是否在同一秒传递同一个重点 + +### 5. 用词习惯 + +- 高频词 Top 10-15(脚本统计仅为候选线索) +- 口头禅与高频连接词 +- 情绪词偏好 + +### 6. 口播节奏 + +- 句长分布(短/中/长)与短句连发模式 +- 停顿与留白位置(结合时间戳判断) +- 口播密度(字/分钟,脚本统计需 `--duration`) +- 设问自答频率、排比/对比使用频率 + +### 7. 语气与人设感 + +- 说话方式:朋友式 / 专家式 / 陪伴成长式比例 +- 亲密度与距离感;人称使用偏好(我/你/我们) +- 人设张力:真实短板、热情、判断标准如何成为记忆点 +- 人设边界:不编造悲惨经历、虚假失败、身份标签(编造即失格,不进 DNA) + +## 三、视觉与制作(3) + +### 8. 镜头语言 + +- 出镜形态:真人口播、旁白 + 空镜、产品实拍、屏幕录制、情景演绎 +- 场景选择与记忆点(场景放大) +- 景别与机位切换频率、运镜方式 +- 动作元素:边做什么边讲(实测、挑战、拆箱、复刻、限时完成) + +### 9. 画面风格 + +- 色调与滤镜倾向(暖/冷、高饱和/低饱和) +- 字幕样式:字体粗细、位置、背景框、重点字变色放大 +- 贴纸、特效、转场的使用密度与克制程度 +- 信息密度:画面元素多寡与留白 + +### 10. 声音与BGM + +- 声音形态:原声口播、TTS 配音、旁白、纯画面 + 字幕 +- BGM 类型与节奏(卡点 / 铺底 / 无 BGM) +- 音效使用(强调音、转场音、环境声) +- 音量层次:人声与 BGM 的主从关系 + +## 四、结构与节奏(4) + +### 11. 视频结构模式 + +- 总时长与时长带(<15s / 15-60s / 1-3min / >3min) +- 开场(钩子)—主体—高潮转折—收尾的时长占比 +- 单条完整叙事还是系列切片 +- 分段方式:一气呵成还是明显小节(字幕分段 / 场景切换) + +### 12. 叙事节奏 + +- 信息密度波动:密集输出段与喘息段交替 +- 节奏切换点位置(几秒处换场景 / 换论点 / 上钩子回收) +- 高光位置:最强信息或最强情绪放在哪里 +- 完播设计:中段是否有持续悬念或递进承诺 + +### 13. 冲突与张力 + +- 放大方式:场景放大、动作放大、冲突放大、系列放大、人格放大 +- 反差与矛盾的呈现位置(前置还是后置揭晓) +- 限制条件与失败现场的使用 +- 检查项:放大层是否服务内容本身,是否只剩噱头 + +### 14. 专业度体现 + +- 证据与演示方式:实物、实操、数据、对比、案例 +- 拆解深度:结论复述还是过程与判断展示 +- 术语密度与解释方式 +- 边界标注:适用条件、样本限制、不确定性的说明习惯 + +## 五、互动与标记(3) + +### 15. 互动设计 + +- 评论引导方式:具体问题、选择题(A 还是 B)、征集案例、关键词评论 +- 引导位置与密度;是否合规(不诱导、不以利益换互动) +- 评论区经营痕迹:置顶、作者回复风格 +- 关注引导:系列承诺、主页价值、下集预告 + +### 16. 签名式标记 + +- 固定开场白 / 结束语 +- 口头禅与标志性表达片段 +- 标志性动作、道具、场景或剪辑习惯 +- 系列符号:固定角标、固定 BGM、固定字幕样式 + +### 17. 系列化与合集 + +- 系列设定:单条是否属于持续任务("7 天改造""从不会到能做") +- 集与集之间的钩子:本集结尾如何预告下一集 +- 合集归属与命名习惯 +- 内容支柱意识:系列是否服务于同一人群 / 同一能力线 + +## 分析要求 + +### DNA report 要求 + +除封面 / 首帧外,每个维度至少包含: + +1. **单条结论**:这条视频在该维度上的具体做法。 +2. **原文证据**:口播逐字引用(注明时间区间)或画面描述。 +3. **可复用创作信号**:聚合时值得进入 DNA 文档的信号。 + +单条 report 不需要判断跨条稳定性;这个判断属于 DNA 文档聚合阶段。 + +钩子维度必须逐字摘录前 3 秒口播 / 首帧字幕,不概括、不改写。 + +封面 / 首帧的 DNA report 必须记录图片路径、视觉模型分析结果、AIGC 复现提示词要素和无法判断的信息。没有图片时不得编造视觉证据。 + +### DNA 文档要求 + +除封面 / 首帧外,每个维度至少包含: + +1. **聚合结论**:当前账号采用的规则。 +2. **报告依据**:来自哪些 DNA report、各自权重和 focus。 +3. **创作规则**:生产时必须怎么做,避免只写"有网感""节奏快"这类空泛形容词。 + +聚合时要区分高频共性、高权重样本偏好和用户的明确指示。用户输入必须先映射到具体维度,再转译为聚合结论和创作规则。 + +封面 / 首帧聚合时必须输出可复用的视觉生成规则和 AIGC 提示词模板,并说明覆盖多少张封面 / 首帧、哪些元素是共性、哪些是孤例。 + +样本规则: + +- 默认所有 DNA report 权重为 1。 +- 用户可指定某条 report 权重更高,或通过 focus 限制它只影响某些维度。 +- 聚合结论必须说明依据,不把统计表直接当结论。 +- Agent 必须回读 report 与必要转录原文,避免只看数值。 +- 互动数据(播放/点赞/评论/分享/收藏)只能来自用户提供的数据线索或平台记录,不得编造;数据好坏不直接等于内容风格好坏。 + +## DNA 文档到模板的推导 + +DNA 文档完成后,必须把可复用结论转成生产模板: + +- 选题角度转成选题入口和受众关联要求。 +- 标题与文案转成标题类型偏好、话题标签策略,同时选择代表性标题作为示例直接记录到模板中。 +- 封面 / 首帧特征转成封面风格规则和 AIGC 提示词要素。 +- 其余部分按固定语义结构组织为"起、承、转、合、CTA"五个部分(对应短视频的黄金开场、主体推进、高潮转折、收尾、互动引导)。 +- 每个部分写明本段任务、执行方式、必须做和避免项。 +- 固定的是七个模板部分,不是物理时长占比;任一部分可以对应视频中的一个或多个段落,也可以在特定内容形态下弱化。 +- 每个部分的规则必须能从 DNA 文档的 17 维聚合结论推导,不得引入样本和用户输入之外的新规则。 +- 用户输入必须先映射到具体维度,再转译为模板中的执行规则。 + +七个部分与 17 维的推导关系: + +| 模板部分 | 必须吸收的 DNA 维度 | +| --- | --- | +| 选题 | 选题角度、选题与受众关联(观看理由) | +| 标题(含封面) | 标题类型、参考标题、话题标签策略、封面 / 首帧风格与 AIGC 提示词要素 | +| 起 | 3秒钩子、选题角度、口播节奏、语气与人设感、镜头语言 | +| 承 | 视频结构模式、叙事节奏、口播节奏、用词习惯、专业度体现、镜头语言、画面风格 | +| 转 | 冲突与张力、叙事节奏、语气与人设感、口播节奏、声音与BGM | +| 合 | 视频结构模式、签名式标记、语气与人设感、系列化与合集 | +| CTA | 互动设计、选题与受众关联、语气与人设感 | + +推荐形态: + +```markdown +(选题角度推荐:xxx、xx、xxx) +(选题需考虑的受众关联角度:xxx) + +[标题](类型为主:xxx、xx、xx) +(参考:xxx、xxx、xxx) +(话题标签策略:xxx) +(封面 / 首帧风格:xxx) +(封面 AIGC 提示词要素:xxx) + +[起部分] +(本段任务:在 3 秒内给出观看理由) +(钩子类型:xxx) +(开场口播:xxx) +(画面呈现:xxx) +(句式节奏:xxx) +(必须做:xxx) +(避免:xxx) + +[承部分] +(本段任务:xxx) +(推进逻辑:xxx) +(信息密度:xxx) +(镜头与画面:xxx) +(口播表达:xxx) +(证据与素材:xxx) +(必须做:xxx) +(避免:xxx) + +[转部分] +(本段任务:xxx) +(转折触发:xxx) +(冲突与反差:xxx) +(情绪表达:xxx) +(句式节奏:xxx) +(必须做:xxx) +(避免:xxx) + +[合部分] +(本段任务:xxx) +(收束方式:xxx) +(情绪落点:xxx) +(签名式标记:xxx) +(必须做:xxx) +(避免:xxx) + +[CTA部分] +(行动目标:xxx) +(表达方式:xxx) +(时机与位置:xxx) +(受众关联:xxx) +(必须做:xxx) +(避免:xxx) +``` + +## 更新规则 + +- 新视频先形成单条 DNA report,再与历史 report 合并重新聚合。 +- Agent 需要比较新旧结论:保留共性、记录变化、移动孤例。 +- 用户明确指定只参考某一块时,只更新对应维度,其他维度不得静默改写。 +- 用户输入必须转译为 affected dimensions、DNA 修改、template 修改和执行规则;原话只保留在转译来源中。 +- 更新后 DNA 文档与模板必须同步,避免两者表达两套规则。 diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py new file mode 100644 index 00000000..a6919ea9 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py @@ -0,0 +1,959 @@ +#!/usr/bin/env python3 +import argparse, json, math, re, shutil +from collections import Counter, defaultdict +from datetime import datetime, timezone +from pathlib import Path +from statistics import median + + +SENTENCE_SPLIT = re.compile(r"[。!?!?]+") +TOKEN_RE = re.compile(r"[一-鿿A-Za-z0-9_]+") +ENGLISH_WORD_RE = re.compile(r"[A-Za-z0-9_]+") +SECOND_PERSON_RE = re.compile(r"你们?|you", re.IGNORECASE) +FIRST_PERSON_RE = re.compile(r"我们?|I|we", re.IGNORECASE) +QUESTION_RE = re.compile(r"[??]") +EXCLAMATION_RE = re.compile(r"[!!]") +ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,63}$") +SOURCE_BLOCK_RE = re.compile(r"", re.DOTALL) +REPORT_BLOCK_RE = re.compile(r"", re.DOTALL) + +STATISTICS_METRICS = [ + "avg_sentence_tokens", + "question_density_per_100_sentences", + "second_person_density_per_100_sentences", + "first_person_density_per_100_sentences", + "exclamation_density_per_1000_characters", + "speech_chars_per_minute", +] + +STOP_TERMS = { + "一个", "我们", "你们", "这里", "不会", "这个", "那个", "什么", "可以", "因为", + "但是", "所以", "还是", "以及", "如果", "他们", "自己", "的时候", "to", "the", + "a", "an", "is", "are", "and", "or", "of", "in", "for", "on", "with", "you", "we", +} + +DIMENSION_GROUPS = { + "选题与包装": [ + ("topic-angle", "选题角度"), + ("title-style", "标题与文案"), + ("cover-frame", "封面与首帧"), + ], + "钩子与表达": [ + ("hook", "3秒钩子"), + ("word-habit", "用词习惯"), + ("speech-rhythm", "口播节奏"), + ("tone", "语气与人设感"), + ], + "视觉与制作": [ + ("shot-language", "镜头语言"), + ("visual-style", "画面风格"), + ("sound-design", "声音与BGM"), + ], + "结构与节奏": [ + ("video-structure", "视频结构模式"), + ("narrative-rhythm", "叙事节奏"), + ("conflict-tension", "冲突与张力"), + ("professionalism", "专业度体现"), + ], + "互动与标记": [ + ("interaction-design", "互动设计"), + ("signature", "签名式标记"), + ("series-design", "系列化与合集"), + ], +} +DIMENSIONS = [] +number = 1 +for group, dimensions in DIMENSION_GROUPS.items(): + for dimension_id, name in dimensions: + DIMENSIONS.append( + {"id": dimension_id, "number": number, "name": name, "group": group} + ) + number += 1 + + +def split_sentences(text: str) -> list[str]: + return [item.strip() for item in SENTENCE_SPLIT.split(text) if item.strip()] + +def tokenize(text: str) -> list[str]: + return TOKEN_RE.findall(text) + +def rounded(value: float) -> float: + return round(value, 4) + +def safe_ratio(numerator: int, denominator: int, multiplier: float = 1) -> float: + return rounded((numerator / denominator) * multiplier) if denominator else 0.0 + +def average(values: list[float]) -> float: + return rounded(sum(values) / len(values)) if values else 0.0 + + +def extract_terms(text: str) -> Counter: + terms: Counter = Counter() + for token in tokenize(text): + if re.fullmatch(r"[A-Za-z0-9_]+", token): + term = token.lower() + if term not in STOP_TERMS and len(term) > 1: + terms[term] += 1 + continue + cleaned = "".join(ENGLISH_WORD_RE.sub("", token).split()) + if len(cleaned) == 1: + continue + for start in range(len(cleaned) - 1): + term = cleaned[start : start + 2] + if term not in STOP_TERMS: + terms[term] += 1 + return terms + + +def title_candidates(path: Path, text: str) -> list[str]: + candidates = [path.stem] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("# ") and len(stripped) > 2: + candidates.append(stripped[2:].strip()) + break + return candidates + + +def document_metrics(path: Path, text: str, duration: float = 0.0) -> dict: + sentences = split_sentences(text) + sentence_lengths = [len(tokenize(sentence)) for sentence in sentences] + sentence_count = len(sentences) + character_count = len(text) + speech_chars_per_minute = ( + rounded(character_count / duration * 60) if duration > 0 else 0.0 + ) + + return { + "source_transcript": str(path.resolve()), + "title_candidates": title_candidates(path, text), + "characters": character_count, + "sentences": sentence_count, + "duration": duration, + "avg_sentence_tokens": average([float(value) for value in sentence_lengths]), + "question_density_per_100_sentences": safe_ratio( + len(QUESTION_RE.findall(text)), sentence_count, 100 + ), + "second_person_density_per_100_sentences": safe_ratio( + len(SECOND_PERSON_RE.findall(text)), sentence_count, 100 + ), + "first_person_density_per_100_sentences": safe_ratio( + len(FIRST_PERSON_RE.findall(text)), sentence_count, 100 + ), + "exclamation_density_per_1000_characters": safe_ratio( + len(EXCLAMATION_RE.findall(text)), character_count, 1000 + ), + "speech_chars_per_minute": speech_chars_per_minute, + "terms": dict(extract_terms(text)), + } + + +def collect_input_paths(inputs: list[str], suffixes: set[str]) -> list[Path]: + paths: list[Path] = [] + for input_value in inputs: + input_path = Path(input_value).expanduser() + if not input_path.exists(): + raise SystemExit(f"Input does not exist: {input_path}") + if input_path.is_dir(): + paths.extend( + path + for path in input_path.rglob("*") + if path.is_file() and path.suffix.lower() in suffixes + ) + elif input_path.is_file() and input_path.suffix.lower() in suffixes: + paths.append(input_path) + else: + raise SystemExit(f"Input must be a {', '.join(sorted(suffixes))} file or directory: {input_path}") + unique = {path.resolve(): path for path in paths} + return sorted(unique.values(), key=lambda path: str(path)) + + +def validate_cover_image(path_value: str) -> Path: + path = Path(path_value).expanduser() + if not path.is_file(): + raise SystemExit(f"Cover image does not exist: {path}") + if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: + raise SystemExit("--cover-image must be a local .jpg/.jpeg/.png/.webp/.gif file") + return path + + +def persist_cover_image(cover_image: Path, report_output_dir: Path, sample_id: str) -> Path: + cover_dir = report_output_dir.parent / "covers" + cover_dir.mkdir(parents=True, exist_ok=True) + destination = cover_dir / f"{sample_id}{cover_image.suffix.lower()}" + shutil.copyfile(cover_image, destination) + return destination + + +def yaml_value(value: object) -> str: + return json.dumps(value, ensure_ascii=False) + + +def generated_at() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def metric_label(metric_name: str) -> str: + labels = { + "avg_sentence_tokens": "平均口播句长(token)", + "question_density_per_100_sentences": "问句密度 / 百句", + "second_person_density_per_100_sentences": "第二人称密度 / 百句", + "first_person_density_per_100_sentences": "第一人称密度 / 百句", + "exclamation_density_per_1000_characters": "感叹号密度 / 千字", + "speech_chars_per_minute": "口播密度(字/分钟)", + } + return labels.get(metric_name, metric_name) + + +def weighted_median(pairs: list[tuple[float, float]]) -> float: + ordered = sorted(pairs, key=lambda pair: pair[0]) + total_weight = sum(weight for _, weight in ordered) + if total_weight == 0: + return 0.0 + midpoint = total_weight / 2 + cumulative = 0.0 + for value, weight in ordered: + previous = cumulative + cumulative += weight + if cumulative >= midpoint and previous < midpoint: + return rounded(value) + return rounded(ordered[-1][0]) + + +def weighted_mad(pairs: list[tuple[float, float]], center: float) -> float: + deviations = [(abs(value - center), weight) for value, weight in pairs] + return weighted_median(deviations) + + +def build_statistics(reports: list[dict]) -> dict: + total_weight = rounded(sum(report["weight"] for report in reports)) + numeric_metrics = {} + for metric_name in STATISTICS_METRICS: + pairs = [ + (float(report["document"][metric_name]), float(report["weight"])) + for report in reports + ] + center = weighted_median(pairs) + numeric_metrics[metric_name] = { + "weighted_median": center, + "weighted_mad": weighted_mad(pairs, center), + "min": rounded(min(value for value, _ in pairs)) if pairs else 0.0, + "max": rounded(max(value for value, _ in pairs)) if pairs else 0.0, + } + + term_weights: dict[str, float] = defaultdict(float) + term_counts: dict[str, list[int]] = defaultdict(list) + for report in reports: + for term, count in report["document"]["terms"].items(): + term_weights[term] += report["weight"] + term_counts[term].append(count) + stable_terms = [] + for term, coverage_weight in term_weights.items(): + stable_terms.append( + { + "term": term, + "weighted_coverage": rounded(coverage_weight / total_weight), + "report_count": len(term_counts[term]), + "median_count_per_report": rounded(median(term_counts[term])), + } + ) + stable_terms.sort( + key=lambda item: (item["weighted_coverage"], item["report_count"], item["term"]), + reverse=True, + ) + + weights = [report["weight"] for report in reports] + return { + "report_count": len(reports), + "total_weight": total_weight, + "weighting": "user-specified" if any(abs(weight - 1) > 1e-9 for weight in weights) else "uniform", + "numeric_metrics": numeric_metrics, + "stable_terms": stable_terms[:30], + "weighted_coverage": sorted( + ( + { + "report_id": report["report_id"], + "weight": report["weight"], + "focus": report["focus"], + } + for report in reports + ), + key=lambda item: item["weight"], + reverse=True, + ), + } + + +def statistics_markdown(statistics: dict) -> str: + lines = [ + "| 指标 | 加权中位数 | 加权 MAD | 最小值 | 最大值 |", + "|---|---:|---:|---:|---:|", + ] + for metric_name, values in statistics["numeric_metrics"].items(): + lines.append( + f"| {metric_label(metric_name)} | {values['weighted_median']} | {values['weighted_mad']} | {values['min']} | {values['max']} |" + ) + lines.extend( + [ + "", + f"权重模式:`{statistics['weighting']}`;总权重:`{statistics['total_weight']}`。", + "口播密度(字/分钟)仅在 report 提供 `duration` 时有意义;未提供时该行只是 0 值占位。", + "统计只用于辅助聚合,不生成评分;定性判断必须回到各篇 DNA report。", + ] + ) + return "\n".join(lines) + + +def parse_frontmatter(markdown: str) -> dict[str, str]: + match = re.match(r"^---\n(?P.*?)\n---\n", markdown, re.DOTALL) + if not match: + raise SystemExit("DNA markdown is missing frontmatter") + values = {} + for line in match.group("body").splitlines(): + if ":" in line: + key, value = line.split(":", 1) + values[key.strip()] = value.strip() + return values + + +def parse_quoted(value: str) -> str: + try: + return json.loads(value) + except json.JSONDecodeError: + return value.strip('"') + + +def parse_json_list(value: str) -> list: + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [] + except json.JSONDecodeError: + return [] + + +def parse_duration(value: str) -> float: + try: + duration = float(value) + except ValueError: + return 0.0 + return duration if math.isfinite(duration) and duration > 0 else 0.0 + + +def hidden_path(block_match, path_key: str = "paths") -> list[Path]: + if not block_match: + return [] + return [Path(line.strip()) for line in block_match.group(path_key).splitlines() if line.strip()] + + +def load_reports(paths: list[Path]) -> list[dict]: + reports = [] + for path in paths: + markdown = path.read_text(encoding="utf-8") + metadata = parse_frontmatter(markdown) + source_match = SOURCE_BLOCK_RE.search(markdown) + source_paths = hidden_path(source_match, "path") + source_path = source_paths[0] if source_paths else Path(parse_quoted(metadata.get("source-transcript", ""))) + if not source_path.is_file(): + raise SystemExit(f"DNA report source transcript does not exist: {source_path}") + duration = parse_duration(metadata.get("duration", "0")) + document = document_metrics( + source_path, + source_path.read_text(encoding="utf-8", errors="ignore"), + duration, + ) + reports.append( + { + "report_path": str(path.resolve()), + "dna_id": parse_quoted(metadata.get("dna-id", "")), + "report_id": parse_quoted(metadata.get("report-id", path.name.removesuffix(".report.md"))), + "title": parse_quoted(metadata.get("title", document["title_candidates"][-1])), + "weight": float(metadata.get("weight", "1")), + "focus": parse_json_list(metadata.get("focus", "[]")), + "document": document, + } + ) + return reports + + +def report_dimension_markdown(dimension: dict) -> str: + heading = f"### {dimension['number']}. {dimension['name']}" + if dimension["id"] == "cover-frame": + return ( + f"{heading}\n\n" + "**视觉模型分析:**待 Agent 基于封面 / 首帧图补齐。\n\n" + "- 画面主体与场景:待 Agent 补齐。\n" + "- 构图与画幅:待 Agent 补齐。\n" + "- 色彩体系:待 Agent 补齐。\n" + "- 光线与质感:待 Agent 补齐。\n" + "- 风格与媒介:待 Agent 补齐。\n" + "- 文字视觉与图文关系(封面字 / 贴纸 / 标题框):待 Agent 补齐。\n" + "- 品牌识别元素:待 Agent 补齐。\n" + "- 避免项:待 Agent 补齐。\n\n" + "**AIGC 复现提示词要素:**待 Agent 补齐;要求能据此生成风格高度一致的封面图。\n\n" + "**可复用创作信号:**待 Agent 补齐。" + ) + if dimension["id"] == "hook": + return ( + f"{heading}\n\n" + "**钩子原文(前 3 秒口播 / 首帧字幕):**待 Agent 逐字摘录。\n\n" + "**单条结论:**待 Agent 补齐(钩子类型、是否对应定位、正文是否兑现)。\n\n" + "**原文证据:**待 Agent 补齐。\n\n" + "**可复用创作信号:**待 Agent 补齐。" + ) + return ( + f"{heading}\n\n" + "**单条结论:**待 Agent 补齐。\n\n" + "**原文证据:**待 Agent 补齐(口播逐字引用或画面描述,注明时间区间)。\n\n" + "**可复用创作信号:**待 Agent 补齐。" + ) + + +def report_markdown( + dna_id: str, + report_id: str, + weight: float, + focus: list[str], + document: dict, + cover_image: str, + source_url: str, +) -> str: + dimensions = [] + for dimension in DIMENSIONS: + dimensions.append(report_dimension_markdown(dimension)) + duration_line = f"duration: {document['duration']}" + return "\n\n".join( + [ + "---\n" + f"dna-id: {yaml_value(dna_id)}\n" + f"report-id: {yaml_value(report_id)}\n" + "type: dna-report\n" + f"title: {yaml_value(document['title_candidates'][-1])}\n" + f"source-transcript: {yaml_value(document['source_transcript'])}\n" + f"source-url: {yaml_value(source_url)}\n" + f"cover-image: {yaml_value(cover_image)}\n" + f"{duration_line}\n" + f"weight: {weight}\n" + f"focus: {yaml_value(focus)}\n" + "sample_count: 1\n" + f"generated_at: {yaml_value(generated_at())}\n" + "---", + f"# {document['title_candidates'][-1]} 单条 DNA Report", + "本文件只描述这一条视频。它不是聚合后的 DNA 文档,也不直接作为生产模板。", + "## 单条统计", + f"- 转录字符:{document['characters']}\n- 口播句子:{document['sentences']}\n- 视频时长:{document['duration'] or '未提供'}\n- 标题候选:{' / '.join(document['title_candidates'])}\n- 来源链接:{source_url or '未提供'}\n- 封面 / 首帧图:{cover_image or '未提供'}", + f"## {len(DIMENSIONS)} 维单条分析", + "\n\n".join(dimensions), + "## 单条边界", + "- 这里记录本条视频的可复用信号,不判断跨条稳定性。\n- 聚合时由 Agent 根据 DNA report、权重和 focus 判断共性、偏好和例外。", + f"", + *( [f""] if cover_image else [] ), + ] + ) + "\n" + + +def user_input_markdown(user_inputs: list[str], existing_body: str | None = None) -> str: + if not user_inputs: + return existing_body or "暂无待转译输入。" + entries = [] + for index, user_input in enumerate(user_inputs, start=1): + entries.append( + f"### 输入 {index}\n" + f"- raw_input: {yaml_value(user_input)}\n" + f"- affected_dimensions: 待 Agent 映射到 {len(DIMENSIONS)} 个维度 ID\n" + "- dna_document_change: 待 Agent 转译为聚合结论 / 报告依据 / 创作规则\n" + "- template_change: 待 Agent 转译为具体执行规则\n" + "- status: pending" + ) + if existing_body and existing_body != "暂无待转译输入。": + return existing_body + "\n\n" + "\n\n".join(entries) + return "\n\n".join(entries) + + +def dna_document_markdown( + dna_id: str, + reports: list[dict], + statistics: dict, + user_inputs: list[str] | None = None, + previous_dna: str | None = None, +) -> str: + old_sections = extract_markdown_sections(previous_dna, "### ") + dimensions = [] + for dimension in DIMENSIONS: + heading = f"### {dimension['number']}. {dimension['name']}" + body = old_sections.get(heading) + if body: + dimensions.append(f"{heading}\n\n{body}") + else: + if dimension["id"] == "cover-frame": + dimensions.append( + f"{heading}\n\n**聚合结论:**待 Agent 补齐。\n\n" + "**报告依据:**待 Agent 列出使用的封面 / 首帧图和 DNA report。\n\n" + "**视觉生成规则:**待 Agent 转成可执行的 AIGC 提示词要素。\n\n" + "**例外与约束:**待 Agent 补齐。" + ) + else: + dimensions.append( + f"{heading}\n\n**聚合结论:**待 Agent 补齐。\n\n" + "**报告依据:**待 Agent 列出使用的 DNA report、权重和 focus。\n\n" + "**创作规则:**待 Agent 补齐。" + ) + report_paths = "\n".join(report["report_path"] for report in reports) + existing_user_inputs = ( + extract_named_section(previous_dna, "## 用户输入转译区") if previous_dna else None + ) + return "\n\n".join( + [ + "---\n" + f"dna-id: {yaml_value(dna_id)}\n" + "type: dna-document\n" + f"report_count: {statistics['report_count']}\n" + f"total_weight: {statistics['total_weight']}\n" + f"weighting: {statistics['weighting']}\n" + f"generated_at: {yaml_value(generated_at())}\n" + "---", + f"# {dna_id} DNA 文档", + "本文件聚合历史 DNA report。它是账号当前采用的短视频内容与风格规则,也必须能推导出生产模板。", + "## 报告与权重", + "\n".join( + f"- `{report['report_path']}`:weight `{report['weight']}`,focus `{', '.join(report['focus']) or 'all'}`" + for report in reports + ), + statistics_markdown(statistics), + f"## {len(DIMENSIONS)} 维聚合", + "\n\n".join(dimensions), + "## 用户输入转译区", + user_input_markdown(user_inputs or [], existing_user_inputs), + "## 推导规则", + "- 聚合结论必须能追溯到 DNA report。\n- 用户输入必须先映射到具体维度,再修改聚合结论和创作规则;不得把原话直接当成 DNA 规则。\n- 模板必须由本文件推导,不能引入本文件未确认的规则。", + f"", + ] + ) + "\n" + + +def extract_markdown_sections(markdown: str | None, heading_prefix: str) -> dict[str, str]: + if not markdown: + return {} + sections = {} + current_heading = None + current_level = 0 + current_lines = [] + for line in markdown.splitlines(): + match = re.match(r"^(?P#{1,6})\s+", line) + if match: + level = len(match.group("level")) + if current_heading is not None and level <= current_level: + sections[current_heading] = "\n".join(current_lines).strip() + current_heading = None + current_level = 0 + current_lines = [] + if line.startswith(heading_prefix): + current_heading = line + current_level = level + current_lines = [] + elif current_heading is not None: + current_lines.append(line) + continue + if current_heading is not None: + current_lines.append(line) + if current_heading is not None: + sections[current_heading] = "\n".join(current_lines).strip() + return sections + + +def extract_named_section(markdown: str, heading: str) -> str: + lines = markdown.splitlines() + found = False + body = [] + for line in lines: + if found: + if line.startswith("## "): + break + body.append(line) + elif line.strip() == heading: + found = True + return "\n".join(body).strip() + + +TEMPLATE_STAGES = ("起", "承", "转", "合", "CTA") + +TEMPLATE_STAGE_FIELDS = { + "起": ( + "本段任务", + "钩子类型", + "开场口播", + "画面呈现", + "句式节奏", + "必须做", + "避免", + ), + "承": ( + "本段任务", + "推进逻辑", + "信息密度", + "镜头与画面", + "口播表达", + "证据与素材", + "必须做", + "避免", + ), + "转": ( + "本段任务", + "转折触发", + "冲突与反差", + "情绪表达", + "句式节奏", + "必须做", + "避免", + ), + "合": ( + "本段任务", + "收束方式", + "情绪落点", + "签名式标记", + "必须做", + "避免", + ), + "CTA": ( + "行动目标", + "表达方式", + "时机与位置", + "受众关联", + "必须做", + "避免", + ), +} + + +def parse_template_fields(body: str) -> dict[str, str]: + fields = {} + for line in body.splitlines(): + match = re.match(r"^((?P