chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:17 +08:00
commit 50bbe68809
112 changed files with 16932 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Refresh Star History
# Regenerates docs/star-history.svg from the repo's own star data.
# Needed because GitHub restricts stargazer `starred_at` timestamps to a
# repo's admins/collaborators, so star-history.com's anonymous embed no
# longer renders. The repo's own token CAN read the data, so we self-host.
#
# Token: uses GITHUB_TOKEN by default. If that turns out not to have access
# to starred_at, add a fine-grained PAT (Metadata: Read-only) as the repo
# secret STAR_HISTORY_TOKEN and it will be used automatically.
on:
schedule:
- cron: '0 3 * * 1' # every Monday 03:00 UTC
workflow_dispatch: # manual "Run workflow" button
permissions:
contents: write
concurrency:
group: star-history
cancel-in-progress: true
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Regenerate star-history SVG
env:
GH_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN || github.token }}
run: python3 tools/gen-star-history.py
- name: Commit if changed
run: |
if [[ -n "$(git status --porcelain docs/star-history.svg)" ]]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add docs/star-history.svg
git commit -m "chore: refresh star-history snapshot [skip ci]"
git push
else
echo "star-history.svg unchanged — nothing to commit."
fi
+61
View File
@@ -0,0 +1,61 @@
# macOS / iCloud
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
# Claude Code per-user config (含本地路径 / 个人 permission grants)
# 整个 .claude/ 目录在源码 repo 里都是本地状态——hooks/settings 的项目级配置走用户自己的 content project 不走这里
.claude/
# Python (whisper adapter / score-curve.py 跑过留下的)
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.python-version
# Secrets / environment
.env
.env.*
# SQLite databases
*.sqlite
*.sqlite3
# 用户项目数据(万一开发者在 cheat-on-content 源码目录里跑了 cheat-init
# 这些文件应该在用户自己的 content project 里,不该污染源码 repo
.cheat-state.json
.cheat-cache/
.cheat-hooks/
.cheat-secrets.json
predictions/
videos/
scripts/
samples/
candidates.md
rubric_notes.md
script_patterns.md
benchmark.md
STATUS.md
WORKFLOW.md
content.db
# adapter 跑过留下的
.auth/
.auth-xhs/
.auth-linkedin/
.debug/
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
# 临时文件
*.tmp
*.log
+337
View File
@@ -0,0 +1,337 @@
# Changelog
All notable changes to cheat-on-content will be documented here.
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
---
## [Unreleased]
### Added — xhs-explore adapter: 公开页兜底、正文/图片归档、账号汇总
- 融合 [xhs-analytics](https://github.com/SingularGuyLeBorn/xhs-analytics) 的公开页解析能力:
- `crawler.fetch_public_note()` 无登录解析 `window.__INITIAL_STATE__`,补全正文、图片 URL、标签。
- `crawler.fetch_public_comments()` 当前台 API 拿不到评论时,用公开页 top 评论兜底(通常 ~10 条)。
- `crawler.download_image()` 异步下载笔记图片。
- `review.py` 新增子命令:
- `archive <notes.json> [output_root] [limit]`:批量归档已发布笔记正文与图片。
- `summarize [out_dir]`:基于创作者中心最近 50 条 + 公开页标签生成账号级汇总。
- `review.py note` 支持环境变量 `XHS_DOWNLOAD_IMAGES=1` 下载图片到 `videos/<...>/images/`
- `renderer.py``report.md` 中新增「正文 + 标签」与「图片」块。
- `requirements.txt` 增加 `requests>=2.28`
- `Session.open()` 在未下载 Playwright Chromium 时自动 fallback 到系统 Chrome`channel="chrome"`)。
### Added — 受众画像 personacheat-persona skill + audience.md
**动机**:用户选题 / 写稿时缺一个清晰的"谁在看"的镜子。新增受众画像功能——从复盘评论数据聚类出账号真实受众。
**设计**persona 是和 rubric **平行**的第二个派生物:
```
复盘数据(评论 + 完播 + 转粉)
├──→ rubric 进化(cheat-bump —— "怎么打分"
└──→ 受众画像(cheat-persona —— "谁在看"
```
- **新 skill `/cheat-persona`**(第 15 个子 skill)—— 扫 `predictions/*.md` 复盘段评论,按"自我认同 / 情绪寄存 / 反驳点 / 语言"四维聚类,写 `audience.md`
- **新文件 `audience.md`** + `templates/audience.template.md` —— 受众画像 reference,和 `benchmark.md` 同级。强制每条"验证特征"带评论证据 + 条数;验证 / 假设 / 反画像三分;强制写"反画像"防 persona 变成讨好自己的虚构
- **⚠️ 污染隔离**`audience.md` 从评论派生 = 含实绩信号。`cheat-score-blind` hard refusal list 加 `audience.md`refusal_code `blocked_audience`。persona 影响 cheat-seed **写什么**creative),不影响 cheat-predict **怎么打分**blind sub-agent 永不读 audience.md
- **cheat-seed 接线**Phase 0 读 audience.md 作为"这个 persona 会在乎吗"的镜子(Confidence 🟡 以上才当检验视角;不进粗打分)
- **cheat-init 接线**Phase 3 脚手架创建空 `audience.md`;导了 benchmark 的用户提示可 `/cheat-persona — seed-from-benchmark`
- **Confidence 分级**:🔴 无数据 / 🟠 benchmark-seed 未验证 / 🟡 1-2 篇复盘 / 🟢 3-5 篇 / 🔵 6+
- **零 schema 变化**persona 元数据全放 audience.md headerversion / last_rebuilt / 数据基础 / Confidence),不动 `.cheat-state.json`
**Phase 1 范围**(本次):上述。**Phase 2 路线**(未做):cheat-recommend persona-fit 排序 / cheat-status 新鲜度 nag / cheat-retro 自动 flag。
**Known limitations**(写进 cheat-persona/SKILL.md):评论 ≠ 全部受众(偏向会评论的活跃少数);评论可被水军污染;persona 滞后于真实受众变化;不替用户做"想要的受众 vs 实际受众"的战略决策。
### Fixed — cheat-seed draft 写成字幕格式(一句一行)
**问题**:用户反馈 cheat-seed 写的 draft 正文是"一句话一行"的字幕格式,而不是段落版。根因不是文档缺失——"不要字幕格式"的指令在 4 个文件里都有,但**全是散文指令**。生成 draft 那一刻,模型"video script = 提词器短行"的训练先验压过了埋在 Phase 4 散文里的一句话。
**修复**(两手):
- **A — 生成时压先验**Phase 4 draft 模板块加 ❌字幕格式 / ✅段落版 的**具象并排对照**,就放在正文占位符旁边——generation 那一刻眼睛在示例上,先验被锚点压住
- **B — 落盘后确定性兜底**:新增 Phase 4.5a line-format 自检——算正文 `avg_chars_per_line``< 15 字 且 行数 ≥ 8` → 判定字幕格式 → 自动重排为 3-6 段段落版。不靠模型自觉,靠 `awk` 判定
- Phase 4.5 顺序固定:**先 4.5a 修版式,再 4.5b humanizer**humanizer 处理散文,喂碎行会乱)
- batch 模式的 N 份 draft 同样走 Phase 4 格式 + Phase 4.5 自检
### Added — cheat-seed Phase 4.5humanizer 自检 pass(去 AI 味)
**问题**Claude 自己写的 cheat-seed 初稿天然带 AI 写作 tells——em-dash 滥用、rule of three、inflated 词汇、空泛归因、-ing 浅层分析。用户拿到的起点"机器味"重。
**改动**:draft 写完落盘后、展示给用户前,新增 Phase 4.5——用 [`humanizer`](https://github.com/blader/humanizer) skillMIT,外部项目,18k stars)过一遍去掉 AI tells。
- 只 humanize **正文**,不动 header 的"必须改写"警告(那是有意的脚手架标记)
- **voice calibration**:有历史脚本 / `script_patterns.md` 时作为参考样本一起传——往"用户的声音"靠,不是"通用人声"
- 报告修了哪些 tell,Phase 5 输出展示
- `HUMANIZE_DRAFT = on` 默认;humanizer 未装时优雅跳过 + 提示如何启用
- **不污染校准**cheat-seed draft 不是被预测/发布的东西——cheat-predict 打分的是用户最终稿,humanize 初稿只是给更干净的起点
- **不替用户改写**humanizer 去 AI 味 ≠ 变成用户的声音,"必须改写"警告依然成立
humanizer **不打包**进 cheat-on-content——用户自己 `git clone``~/.claude/skills/humanizer/`
### Fixed — douyin-session 运行时路径隐私漏洞(@level5Ninja [#16](https://github.com/XBuilderLAB/cheat-on-content/pull/16)
**问题**douyin-session adapter 把 `.auth/`**含抖音登录 cookie**)、debug 截图、report 写进 **skill 源码目录** 而不是用户内容项目——symlink 安装时,用户的会话凭据会落在 cheat-on-content repo 里,有被 commit 的风险。meta-logging hook 还把每条 user prompt 前 120 字存进 `usage.jsonl`,采集过度。
**修复**
- 新增 `adapters/perf-data/douyin-session/paths.py` —— 运行时路径 helper`runtime_project_root` / `auth_dir` / `debug_dir` / `videos_dir`),用 `CHEAT_PROJECT_ROOT` env var + cwd fallback
- `.auth/` → 用户内容项目根;debug 产物 → `.cheat-cache/douyin-session-debug/`report/script → 用户项目 `videos/`(不再散落在 skill 源码树)
- `run.sh` export `CHEAT_PROJECT_ROOT`
- meta-logging hook 不再存 prompt 摘要——改成只记 `prompt_present`bool+ `prompt_chars`(长度)
- docsadapter README + state-management.md)同步
### Fixed — cheat-shoot DIFF_METRIC 在口语化场景的 v2 误触发(**BREAKING for v2-trigger-logic**
**问题**cheat-shoot Phase 3b 用 line-level unified diff 算 `diff_pct = (added + removed) * 100 / orig_lines`。但**创作者真实场景**——draft 是 markdown 长句(一行 ~50 字),拍摄稿是 whisper 转录的口语化短断句(每行 ~5-10 字)——同样的内容会 inflate diff_pct 到 100-200%,触发本不该的 v2 重判。
**实测复现**use clone PR pre-fix):
- draft markdown 63 行 / ~380 字
- 拍摄转录 100 行 / 同 ~380 字
- 内容几乎完全保留(审稿人原句一字不差 + 5 年前反应概念 + 升维段所有金句)
- 唯一新增:1 句 brand 锚定 "全面拥抱 AI"
- **line-level diff_pct = 198%** ⚠️ v2 错误触发
- 语义内容真实 diff ≈ 15-25%
**修复**:拆 metric。
- **DIFF_METRIC=char_levenshtein_normalized**(新默认)—— [tools/diff_pct.py](tools/diff_pct.py) 先 normalize(去 markdown header / 分隔线 / 列表标记 / 装饰标点 / 折叠所有空白),再算 char-level Levenshtein / max(len_a, len_b)
- backend 优先级:`rapidfuzz`C-backed~ms 级;需 `pip install rapidfuzz``difflib.SequenceMatcher`stdlib,永远可用,~10ms 级)
- **V2_TRIGGER_THRESHOLD = 0.30** 保持不动(阈值经验合理)
- legacy line-level 保留为终极 fallback(只在 python3 + tools/diff_pct.py 都不可达时降级)
**测试**3 fixture × 2 backend = 6 case,全过:
| Case | 内容 | 期望范围 | difflib | rapidfuzz |
|---|---|---|---|---|
| 1 | markdown 长句 vs 转录短断句(内容同) | < 30 | 7 | 12 |
| 2 | 完全不同主题 | ≥ 60 | 88 | 97 |
| 3 | 加 20% outro/CTA | 10-30 | 14 | 25 |
`bash tools/diff_pct_test.sh` 复现。
**已知局限**
- 历史 v2 prediction 文件保留 line-level 数字作 audit trail——不重新走过去的 prediction
- normalize 是启发式(中文标点)——对其他语言 / 非典型 markdown 可能需调整 regex
### Fixed — `rubric_notes.md` 实绩泄漏漏洞(**BREAKING for blind channel integrity**
**问题**PR #11 引入的 cheat-score-blind sub-agent 承诺只读 `scripts/<id>.md` + `rubric_notes.md` 两个文件。但 cheat-bump Phase 5 把升级 Memo(含真实视频名 + 实绩 + 派生证据)写进了 `rubric_notes.md`——sub-agent 通过白名单读到了本不该看的实绩,盲打分变成"看过实绩的事后合理化"。实测复现:5 条已发视频里 2 条 sub-agent 自动标 `any_contamination_signal: true`refusal=`non_blind_warning`,所有维度 confidence 降 medium)。
**修复**(拆 file):
- **新增 `rubric-memo.md`**——升级 Memo 累积档案。cheat-bump Phase 5 写**这里****不**写 rubric_notes.md。append 模式累积多次 bump
- **`rubric_notes.md` 严格收窄**——只放公式 + 通用语言维度定义 + bucket 边界 + 顶部 metadata 指向 rubric-memo.md。**绝不**含真实视频名 / 实绩 / 派生证据带命名锚
- **`cheat-score-blind` 硬禁读 `rubric-memo.md`**——refusal_code `blocked_rubric_memo`;同时加白名单文件**兜底自检**(grep 命中实绩 pattern → 标 `non_blind_warning`
- **`cheat-bump` Phase 5 leak guard**——写完 rubric_notes.md 后 grep 自检,命中违禁 pattern → abort + 回滚
- **`shared-references/observation-lifecycle.md` 加约束**——任何 skill 写 rubric_notes.md 都不许含实绩 pattern(防止将来再犯)
**老用户必跑**v0.x 任何已有 `rubric_notes.md` 含 bump Memo 的项目,git pull 后**必须**跑 `/cheat-migrate` 把 rubric_notes.md 拆分为两份文件。不跑 → blind sub-agent 仍泄漏。详见 [migrations/1.3-to-1.4.md](migrations/1.3-to-1.4.md)。
### Changed — schema 1.3 → 1.4MINOR but BREAKING for blind channel
- state 字段**无变化**——`schema_version` bump 仅标识"老用户须跑文件层拆分迁移"
- [migrations/1.3-to-1.4.md](migrations/1.3-to-1.4.md) 7 步标准流程(备份 → 扫描 → 抽离 → 写 rubric-memo.md → 清理 rubric_notes.md → 自检 → bump schema
- cheat-init, SessionStart hook LATEST_SCHEMA, registry.md 三处同步
### Added — Blind scoring sub-agentchannel B 隔离)
**问题**cheat-on-content 的 7/9 维打分原本在主对话 inline 完成——但主 Claude 已经看过用户对话、实绩数据、复盘段历史,打分被污染。`/cheat-bump` Phase 2 校准池重打分时尤其严重——rank 一致性可能 overfit 而非真信号。
**改动**:引入 [skills/cheat-score-blind](skills/cheat-score-blind/SKILL.md) 作为 **channel B** 隔离打分 sub-agent。三 channel 模型:
- **A** = 主对话:决策 / 写 retro / 跟用户交互
- **B** = blind sub-agent (新):只接收 `script_path` + `rubric_notes_path`,硬拒绝读 state file / predictions/ / videos/,输出严格 JSON 9 维分 + per-dim confidence
- **C** = 跨模型 auditqwen-max via `mcp__llm-chat__chat`,已有):bump 终局 sanity check
具体落地:
- **`cheat-score` Step 3** 改为 Task tool delegate 到 cheat-score-blind(不再 inline 打分;cheat-score 无 `--skip-blind` 因为是轻量探索)
- **`cheat-predict` Phase 2** 默认 delegate;新 **Phase 2.5** 做 disagreement detection——blind 与主 Claude 自估 |delta| ≥ 2 弹用户裁定(选 a/b/c);header 新增 `BlindScored By` + `BlindScore Disagreement` 字段(**所有维度必记**,delta=0 也记,作为复盘分析素材)
- **`cheat-predict --skip-blind`** flag 是 escape hatch:触发 `state.last_prediction_self_scored=true` + `last_self_scored_at` 时间戳,cheat-status / SessionStart hook 持续 nag 至下次正常调用清回
- **`cheat-bump` Phase 2** **强制** sub-agent**不接受任何 fallback**——Task tool 不可用 → abort bump,不接受"自审";每条 prediction 的 `Re-scored under vN` 行额外标 `blind: true`
- **SessionStart hook** 检测 `last_prediction_self_scored && days_since >= 7` 输出红色警告
- **install.sh / uninstall.sh** 加 `cheat-score-blind` 到 SKILLS 数组(14 个子 skill
### Changed — schema 1.2 → 1.3MINOR
- 新增 `last_prediction_self_scored: bool`(默认 false+ `last_self_scored_at: ISO 8601 / null`
- [migrations/1.2-to-1.3.md](migrations/1.2-to-1.3.md) 含 4 段标准格式(WHAT/WHY/HOW/Manual fallback
- 老 state 跑 `/cheat-migrate` 升级;不跑也兼容(skills 用 `state.get(field, default)` 兜底)
### Known limitations(写进 cheat-score-blind/SKILL.md
1. **sub-agent ≠ 真独立** —— 同 Claude 模型,RLHF priors 共享;新 context 不等于另一个判分体系
2. **不解决 rubric 设计 bias** —— 用户自己写的 rubric 自然让自己内容显得好。这层 bias 由 channel C 跨模型 audit 和定期 bump 验证解决
3. **不解决 review 阶段的覆盖** —— 主 Claude 拿到 blind 分后,可能在 Phase 2.5 被实绩诱导覆盖。disagreement detection + 用户裁定减轻但不消除
### Changed — README / cheat-init voice 重塑(递归宿命感)
- **README tagline 改递归宿命版**"你正在读这段话——这个 skill 预测过了。... 你停下来思考'这是不是真的'——也在它的预测里。" 替代原"凭感觉发是猜,这套让你算"框架
- **新增 🌀 起源段**:创作者本人视频脚本精华(一阶宿命 → 二阶宿命的觉醒)作为 README 中段叙事 hook
- **closing tagline 加 callback**"你看到这一行——也是它预测的"——首尾呼应把读者卡进预测循环
- **cheat-init Phase 1 首屏同步**:从 "做内容本质上就是作弊" 改成 "你的下一条内容已经在改写 3 个月后的你。规律是客观存在的,区别是你看见还是没看见。这套让你看见。"
- **GitHub repo description** 同步递归版
### Changed — 多语言 README 拆分
- `README.md` 现为**英文默认**——国际用户首屏
- `docs/README_CN.md` 为简体中文(原 README 内容 + 宿命感重塑)
- 两份顶部加 language switcherQuantDinger 风格)
- logo 路径 + 内部链接按相对路径调整
### Added — Star History 图表
两份 README 末尾加 [star-history.com](https://star-history.com) 图表,社区可视化项目热度。
### Changed — 弱化 Claude Code 强调
README 安装段从"### Claude Code"+"### Codex"双标题,改为"默认 + supported agents 列表"——把 skill 包装成跨 agent 的工作流而不是 Claude Code 专属。日常用法段同步换成 "skill-compatible agent" 泛称。
### Added — Terminal-style logo SVG
- `docs/logo.svg`(1.9KB 原生 SVG,无图片资源依赖)
- 终端窗口 + traffic lights + `$ fatesnail` 命令行 + 5 阶段循环 + `// cheat on content` 注释
- README hero 居中嵌入
### Added — cheat-seed Mode 重构 + 双热点工具集成
**问题**:原 Mode B 给"a/b/c 三种例子"让用户讲经历——但同样的话术对**有方向但抽象的用户**("想做职场")和**完全没想法的用户**("帮我想")一视同仁,前者其实有真动机不需要 AI 列举,后者需要的是外部素材而不是 prompt。
**改动**
- **Mode B 改成单问"为什么想做这个主题?"** —— 用户内省窗口,**不调任何热点工具**。3 类回答:含具体经历→转 Mode A;空动机→反问最多 2 轮;真没想法→转 Mode C
- **Mode C 整合外部素材**:按 `content_form` 路由热点工具——AI 类形态调 [aihot](adapters/trend-sources/aihot.md),文化/社会形态调 [trendradar-mcp](adapters/trend-sources/trendradar-mcp.md),混合两个都调;用户挑一条后**回到内省**问"你为啥对这条最有感觉",再转 Mode A 深挖
- **Mode A 灰色场景**(用户给了时事话题):Phase 2A.5 询问"要不要拉外部数据作参考"——**不主动调**,避免外部信息带偏用户视角
- **聊经历三选项移到 Mode C 兜底**:仅当用户拒绝外部素材或外部都不感兴趣时呈现
### Added — 两个一等公民 trend source
- **[aihot](adapters/trend-sources/aihot.md)**Claude skill):[aihot.virxact.com](https://aihot.virxact.com) 的中文 AI 行业每日精选,5 类(模型/产品/行业/论文/技巧)。无 authcurl 公开 APIrate limit 600/min
- **[trendradar-mcp](adapters/trend-sources/trendradar-mcp.md)**MCP server):[TrendRadar](https://github.com/sansan0/TrendRadar)57k starsGPL-3.0 通过 MCP 调用不构成 linking)。25+ MCP 工具——除 `get_latest_news` 外还有 `analyze_topic_trend`(爆火/衰退判定)、`compare_periods`(周环比)、`analyze_sentiment`
### Added — `shared-references/data-source-routing.md`
热点工具的触发与路由协议——单一来源记录:"何时调"(5 个入口的触发矩阵)+ "调哪个"content_form → adapter 路由表)+ "不调时怎么办"(失败降级链)+ Token 成本意识。
### 哲学保持不变
> 热点工具是"前置素材库",不是"主菜单"——AI 给材料,用户决定 angle。
cheat-seed 的核心论点"好内容来自用户的真实经历,AI 不凭空 brainstorm"完全保留。新设计只是让"完全没想法"这条 cold-cold-start 路径不再死锁。
### Added — v2 预测重判系统(拍后改稿场景)
- **append-only v2 prediction**cheat-shoot 检测拍摄稿与 `scripts/<id>.md` 行级 diff ≥ 30%`V2_TRIGGER_THRESHOLD`)→ 自动调用 `/cheat-predict — mode: v2 — prediction-file: <path>` → 在原 prediction 文件 `## 复盘` 之前 append `## 预测 v2 (replaces v1)` 段。**v1 段绝不修改**(hook 物理强制),v2 才进 cheat-retro 的偏差计算
- **immutability hook awk 升级**:单个 `## 预测` 改为可识别多个 `## 预测 vN` 段(v1 / v2 / 任意 vN 一起锁),同时兼容 v0.1.0 的 legacy 裸 `## 预测` 写法。端到端 5 场景验证通过(编辑 v1 / 编辑 v2 / 编辑 legacy 都 BLOCKappend 新段、改 ## 复盘 都 ALLOW
- **cheat-predict 加 Phase 0.7 模式判定**:检测目标 prediction 文件已含 `## 预测...` 段 → 自动切 v2 模式(Edit 在 `## 复盘` 边界 append,不 Write 覆盖)
- **cheat-retro 升级**:识别多个 `## 预测 vN`,取最后一段作校准依据;预测段哈希校验扩展为"全部 v? 段合并哈希",任一被改即报错回滚
- **prediction header 新字段 `Prediction Basis`**`pre_shoot`v1 默认)/ `post_shoot_pre_publish`v2)。score-curve 与 cheat-bump 据此区分两条数据线避免混样
- **shoots[] 项 schema 扩展**:新增 `scripts_path` / `script_consistency` / `script_diff_pct` / `v2_prediction_written` / `script_hash_at_shoot`(详见 [migrations/1.1-to-1.2.md](migrations/1.1-to-1.2.md)
### Changed — schema 1.1 → 1.2MINOR
- 升级 [migrations/registry.md](migrations/registry.md) `LATEST_SCHEMA` 标记 + 版本链表
- cheat-init 新建 state 写 `"schema_version": "1.2"`
- SessionStart hook `LATEST_SCHEMA="1.2"` —— 老用户 git pull + 跑会话 → hook 提示 schema mismatch → 用户跑 `/cheat-migrate` 5 秒升上来。MINOR 兼容,不强制(skills 用 `state.get(field, default)` 兜底)
### Why now
用户实际工作流:写完草稿 → **常常拍摄时即兴改文案** → 草稿和实际播出版本脱节。原"拍前预测,拍后只登记"的严格盲预测让"预测对的稿子"与"实际播出的稿子"不是同一份——校准失真。
v2 系统让"拍后改稿"成为一等公民:v1 留作档案,v2 基于实际拍摄稿重判,diff(v1, v2) 本身成为 rubric 升级的强证据(用户改稿改高了 ER → 工具学到这个用户的 ER 阈值跟当前公式不一致)。盲预测原则保留:v2 仍在发布前完成,没有播放数据可"作弊"。
### Added — Codex 安装兼容(@songth1ef [#6](https://github.com/XBuilderLAB/cheat-on-content/pull/6)
- **`install.sh --codex`**:安装根路由 skill `cheat-on-content` 和 13 个子 skill 到 `~/.codex/skills/`
- **`install.sh --all`**:同时安装 Claude Code 和 Codex skill
- **`uninstall.sh --codex` / `--all`**:对称卸载 Codex 或双端安装
- **Codex 路由说明**:Codex 用自然语言触发同一套流程,不依赖 Claude Code 的 `/cheat-*` slash-command harness
### Added — Migration 系统(让长期迭代不打断老用户)
- **`/cheat-migrate` skill**:把老用户 `.cheat-state.json` 从旧 `schema_version` 升级到当前 `LATEST_SCHEMA`。幂等、不跳版、失败停在断点
- **`migrations/` 目录**:版本演进单一来源
- `registry.md``LATEST_SCHEMA` 标记 + 完整版本链表
- `<from>-to-<to>.md`:每步迁移 4 段(WHAT changed / WHY / HOW Claude steps / Manual fallback
- **`shared-references/migration-protocol.md`**:演进哲学 + maintainer checklistbump schema 必做的 4 件事)
- **SessionStart hook 增强**:检测 `state.schema_version != LATEST_SCHEMA` → 输出非阻塞警告,建议跑 `/cheat-migrate`
- **`install.sh --reinstall-hooks <project>`**git pull 后重写用户项目 `.cheat-hooks/` 的脚本(不动 state / rubric / predictions
- **state-management.md 升级**:所有 schema 升级文档指向 cheat-migrate;明确 MINOR / MAJOR 边界
### Why now
v0.1.0 用户的 state 是 schema 1.1。后续如果改字段语义、删字段、重命名等 → 没有迁移系统的话老用户 git pull 后会卡住。这套系统让"长期迭代不打断老用户"成为常态。
### Fixed
- **cheat-init `content_form` 存成字母 bug**Phase 3 state JSON 模板用 `<Q1>` 抽象占位,导致 Claude 字面把 `"a"` 写进 state 文件而不是 enum `"opinion-video"`。修复:Q1/Q3/Q4/Q5 各加明确字母→enum 映射表 + Phase 3 模板加粗 warning。同时补全 7 个缺失的 `last_*` init 字段(之前靠 `state.get(field, default)` 兜底)+ `enabled_perf_adapters` 派生 + 强制 `initialized_at` 用本地 `+08:00` 时区不用 UTC `Z`
### Changed — README 重写(v0.1.0 ship 后的定位调整)
- 标题:英文 `Cheat on Content`,副标 `网红外挂`(之前 `网红作弊器`
- Tagline 直面"作弊"框架:「做内容本质上就是作弊——谁先看穿规律,谁就拿走流量」
- 新增"那 ChatGPT / 豆包 / DeepSeek 不是也能干这个?"段——核心定位为"你自己的运营专家 + 自动进化"
- 删早期产品警示段(badge + 本 CHANGELOG 已经在传达,重复就是不自信)
- 砍 ARIS attribution(保留多 adapter 设计思路,去掉外部归功)
- README 总长 330 行 → 90 行
- cheat-init Phase 1 首屏文案同步重写:删方法论哲学,2 条 caveats(早期不准 + 强烈建议导对标)
### 余项
- Step B:软化更多硬编码规则
- 完整 reference-implementation 脱敏快照
---
## [0.1.0] — 2026-05-05
> ⚠️ **早期产品(v0.x)—— state schema 仍可能 breaking**
>
> 在 v1.0 之前,每次升级可能改变 `.cheat-state.json` 的字段结构。**升级前建议 backup 你的整个 `<your-channel>/` 目录**。重大 breaking 改动会在本 CHANGELOG 标 `BREAKING`,并在可能的情况下给手动迁移步骤。
### Added
- **方法论 + 12 个子 skill**:完整闭环 init → learn-from → seed → score → predict → shoot → publish → retro → bump,加 status / recommend / trends 辅助
- **3 条不可妥协原则**:盲预测 + 升级=全量重打 + rubric 是工作台不是博物馆(详见 `shared-references/`
- **`/cheat-learn-from` 对标账号导入**:5-10 条对标样本派生 base rubric 信号 + script patterns。两种 input 方式(粘文本 默认 / whisper 转录)+ 两种 data 方式(手填 / adapter 自动抓)
- **Buffer 警戒系统**cadence-protocol):按发布频率派生颜色阈值,断更预警
- **统一预测格式 + confidence 等级**:所有阶段同一 7 组件预测,header 显示 🔴/🟠/🟡/🟢/🔵 信心等级
- **prediction-immutability hook**harness 层强制原则 #1(端到端验证 5/5 通过)
- **SessionStart auto-report hook**:每次开会话自动渲染状态报告
- **跨模型 bump 审核**mcp__llm-chat__chat):rubric 升级时调外部 LLM 独立判定
- **douyin-session adapter**Playwright):自动抓抖音视频 + 评论数据
- **whisper adapter**:转录视频文件为 transcript
- **9 份 templates** + **2 份 starter rubrics**opinion-video v2 校准 / opinion-video-zero v0 等权)
- **score-curve.py**:预测精度收敛曲线诊断工具
### 软规则(Claude 判断为主,非死磕门槛)
下面规则**有默认参考值**但 Claude 可基于强信号软违反:
- bump 触发样本数(默认 ≥5,可基于强反例破例)
- 同向偏差触发(默认连续 ≥3 次,可基于 1 次极端偏差破例)
- benchmark 影响淡出(默认 calibration_samples ≥10,可基于"用户数据 vs benchmark 差异度"破例)
- observation 升格门槛(默认 ≥2 样本,可基于强信号破例)
软违反时 Claude 必须显式标注 `judgment-driven` 让用户审视。
### 硬约束(不可软违反)
- bump 验证 `THRESHOLD = 4/5`(统计刚性)
- prediction immutability hookbinary
- `RETRO_WINDOW_DAYS = 3` 默认(用户可配置 1/7
- 必须有 ≥3 条 benchmark 样本才能拆 pattern
- 必须 ≥20 top 评论才能完成 manual paste 复盘
### 已知 limitations
- **v0.x 无自动 migration**:升级时若 state schema 变了,老用户需手动 wipe + 重 init
- **adapter fragility**:抖音 / 小红书 adapter 依赖反爬绕过,平台改版时可能 break,需要持续维护
- **whisper 中文准确度**medium 模型够用,long-form 准确度一般,关键稿子建议 manual review
---
## 升级指南(pre-v1.0
每次 git pull 之后:
1. **Symlink 模式装(推荐)**:直接生效,无需重装
2. **Copy 模式装**:重跑 `bash install.sh --copy`
3. **如果 CHANGELOG 标了 `BREAKING`**:照 manual migration steps 操作。无 steps 时建议 wipe + 重 init
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cheat-on-content contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+181
View File
@@ -0,0 +1,181 @@
<h1 align="center">
<img src="docs/logo.svg" alt="Cheat on Content" width="720">
</h1>
<h2 align="center">Cheat on Content</h2>
<p align="center">
<strong>English</strong>
&nbsp;·&nbsp;
<a href="docs/README_CN.md"><strong>简体中文</strong></a>
</p>
<p align="center">
<a href="https://watcha.cn/products/cheat-on-content">
<img src="docs/guancha-no1.svg" alt="Watcha Hot List · 观猹热榜 · #1" width="328">
</a>
</p>
<p align="center">
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v0.1.0-orange" alt="Version"></a>
&nbsp;
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
</p>
<p align="center">
For content creators — a skill that turns every post into a calibrated experiment.
</p>
<p align="center">
You're reading this. The skill predicted it.<br>
It turns every "I feel this will go viral" into a calibrated experiment.<br>
It took me from zero to 1M followers in a month. It said I'd write this. I did.<br>
Your doubt — predicted too.
</p>
---
## 🎬 What it actually does
Most creators live in the same gambling loop:
> Publish → Numbers come in → Learn nothing → Roll the dice again
A creator who's shipped 200 pieces is barely 10% sharper than someone who's shipped 1 — because they never **kept books** after each round.
**Cheat on Content** makes every judgment get logged, retrospected, absorbed into the next:
📊 Score → 🎯 Blind-predict → 🚀 Publish → 📈 T+3d retro → 🧬 Evolve your rubric
This isn't motivation. It's **compounding** — every piece you don't retro is silently eroding your ability to see yourself.
One month in = you have a hit-formula that's **only yours**.
Three months in = you're 10× sharper than your first-day self.
---
## 🌀 Origin
> I never believed in fate. Until this skill made me film a video — and predicted exactly how much traffic that video would pull.
>
> I tried to break it. I told my audience. I hoped collective observation would collapse the wave function and shift the trajectory.
>
> The data was accurate.
>
> I didn't escape fate. I just moved from first-order to second-order.
>
> If even my awakening — even my audience's observation — was already in its prediction, then right now, reading this:
> are you here out of curiosity, or just closing the algorithm's last move?
>
> — *the creator*
---
## ⚖️ How it differs from other "creator tools"
| Others | This |
|---|---|
| Give you "inspiration" | Make **your own intuition** measurable |
| AI writes for you | AI **judges** for you — the script stays yours |
| Ship 10 versions, A/B test | Ship one — **bet** in writing, settle the books with data |
| Static dashboard | An **evolving rubric** — your formula 3 months from now isn't the starting one |
In a sentence: other tools help you "ship more." This helps you "judge sharper."
---
## 🤔 Can't I just use ChatGPT / DeepSeek / Doubao?
Those are **general assistants** — they tell everyone the same thing. You ask "will this go viral?" and the answer is fitted to global average opinion, not your channel. Ask again tomorrow — same answer. **It doesn't remember you. It doesn't change because of you.**
This is **your own ops expert** — serving only your one channel:
- The scoring formula is reverse-engineered from **your** history, not the global training distribution
- Every piece you ship updates its understanding — by month three, judgment accuracy is 10× sharper than day one (**auto-evolving**)
- It knows your benchmark account, your cadence, the last three reasons you flopped — things ChatGPT forgets after the first reply
General LLMs help everyone. This helps **your** account.
---
## 🛡️ Why the loop actually evolves
📝 **Every piece is logged**: Score and prediction get written before publish, archived end-to-end. Three days later you settle accounts — you see exactly where you were sharp, where you were off. No more vague "I feel this one didn't land."
🔁 **It gets sharper**: Three same-direction misses in a row, the tool actively prompts you to upgrade your scoring formula. **You don't have to remember — it remembers for you.**
🛡️ **Upgrades have a brake**: Switching the formula requires re-scoring all historical samples — only released if it ranks more accurately than the old. Plus a cross-model independent audit — **so you can't fool yourself.**
🪒 **The rubric is a workbench, not a museum**: Observations refuted by data get deleted; observations absorbed into formal dimensions also get deleted. It only holds what's most useful right now.
---
## 📦 Install
```bash
git clone https://github.com/XBuilderLAB/cheat-on-content.git
cd cheat-on-content
bash install.sh
```
> ⚠️ **Upgrading from v0.x?** Run `/cheat-migrate` in your content project after `git pull`. The 1.3 → 1.4 migration is **BREAKING for blind-channel integrity** — it splits `rubric_notes.md` so the blind sub-agent can't leak actuals. Without migrate, blind scoring will keep flagging `non_blind_warning`. See [CHANGELOG](CHANGELOG.md) and [migrations/1.3-to-1.4.md](migrations/1.3-to-1.4.md).
14 sub-skills are symlinked into your agent's skill directory. One install, every content project gets it.
**Supported agents**: Claude Code (default) · Codex (`bash install.sh --codex`) · Both (`bash install.sh --all`)
> Frozen version: `bash install.sh --copy` / `bash install.sh --codex --copy`
>
> Uninstall: `bash uninstall.sh` / `bash uninstall.sh --codex` (your content data is not touched)
---
## 🚀 First run
In your content project directory, open a skill-compatible agent and say:
```
初始化 cheat-on-content
```
(or `init cheat-on-content`)
Five yes/no questions complete onboarding. **Strongly recommend importing a benchmark account** — 510 samples and the tool gets an anchor immediately. Without one, your first 5 predictions land at ±50% precision.
---
## ⚡ Daily use
```
score this scripts/<...>.md → grade only
start prediction scripts/<...>.md → blind prediction + decision log
shot scripts/<...>.md → create video folder + buffer +1
shipped https://... → buffer -1
retro videos/<...>/ → T+3d data + retrospective
status / fetch trends / find topic / bump rubric / find benchmark
```
Hook-aware agents auto-report buffer + pending retros + top candidates at every session start — no need to ask. Other agents: just say `status`.
Full workflow + sub-skill details: see [SKILL.md](SKILL.md).
---
## 📈 Star History
<a href="https://star-history.com/#XBuilderLAB/cheat-on-content&Date">
<img src="docs/star-history.svg" alt="Star History Chart" width="720">
</a>
---
## 📜 License
MIT. Commercial use, modification, closed-source integration — all fine.
---
*Is this cheating? So was the calculator. So was Google.*
*The future doesn't reward effort — it rewards those who see the pattern first.*
*You reading this line — that's predicted too.*
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`XBuilderLAB/cheat-on-content`
- 原始仓库:https://github.com/XBuilderLAB/cheat-on-content
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+225
View File
@@ -0,0 +1,225 @@
---
name: cheat-on-content
description: 给所有想把"感觉"变成可校准预测的内容创作者。**方法论通用**——打分 → 盲预测 → T+3d 复盘 → 进化 rubric 的循环适用任何能被量化(播放 / 阅读 / 收听 / 点击)的内容。**rubric 是循环的内容,不是循环本身**——当前内置一份观点视频 rubric(参考博主 25+ 视频拟合),其他形态可借这套起步并 bump 调权重。**强烈建议导入对标账号**作为初始信号源(/cheat-learn-from)。触发词:"初始化"/"打分这篇"/"启动预测"/"已发布"/"复盘"/"升级 rubric"/"推荐选题"/"抓热点"/"状态"/"找对标"/"learn from"。**首次使用必须先跑 /cheat-init。**
argument-hint: [draft-path] [— mode: cold-start|calibration]
allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, Skill, mcp__llm-chat__chat
---
# 网红作弊器 / Cheat on Content
> 🎯 **方法论通用,rubric 当前内置观点视频版**
>
> **方法论**(5 阶段闭环):任何能被量化的内容形态都适用——视频 / 文章 / 播客 / Newsletter / 短文 thread。
>
> **当前内置 rubric**:观点类视频(评论 / 时评 / 论说 / 议题讨论 / 个人观点表达),7 个维度由参考博主 25+ 已发样本拟合而来。如果你做其他形态,需要:
> - 自己写一份 rubric(参照 [starter-rubrics/opinion-video-zero.md](starter-rubrics/opinion-video-zero.md) 的格式)
> - 或等内置扩展(长文 / 短文 / 播客 starter 在批次 3 路线图)
>
> 默认假设:**用户是从零开始的新人**(一条视频都没发过)——cold-start 期的预测会**简化**,只要 7 维打分 + 一句话 bet,不强求 bucket 数字(避免 false precision)。已有 5+ 篇数据的老手走 calibration 模式解锁完整 7 组件预测。
把内容创作变成可校准预测循环:**打分 → 预测 → 发布 → 复盘 → 进化 rubric**。
本文件是**总协议 + 路由器**。具体每个阶段的工作流在 `skills/cheat-*/SKILL.md` 各子 skill 里。
## Codex compatibility
Codex 没有 Claude Code 的 slash-command harness。安装到 Codex 后,按自然语言触发同一套路由即可:
- `初始化 cheat-on-content` → 读取并执行 `skills/cheat-init/SKILL.md`
- `打分这篇 scripts/foo.md` → 读取并执行 `skills/cheat-score/SKILL.md`
- `启动预测 scripts/foo.md` → 读取并执行 `skills/cheat-predict/SKILL.md`
- `拍了 ...` / `已发布 ...` / `复盘 ...` / `升级 rubric` / `状态` → 分别读取对应 `skills/cheat-*/SKILL.md`
执行时遵循本文件的三条原则和路由表;不要依赖 `/cheat-*` 命令是否存在。Claude Code 专用 hook`.claude/settings.json`)仍只在 Claude Code 里自动触发;Codex 中需要用户主动说 `状态` 查看 buffer、待复盘和候选池。
---
## 三条不可妥协原则
任何一条被违反,整个校准循环退化为"凭直觉的自我安慰"。如果用户要求打破其中任何一条,**拒绝执行并说明原因**。
1. **盲预测(Blind prediction**:预测必须在看到任何实际数据**之前**写完。一旦写完,`## 预测` 段是 immutable——只能往 `## 复盘` 段追加。完整规范:[shared-references/blind-prediction-protocol.md](shared-references/blind-prediction-protocol.md)。**hooks/prediction-immutability.sh 在 harness 层强制执行**。
2. **升级 = 全量重打(Bump = full re-score**:rubric 升级时,校准池所有有实绩数据的样本必须用新公式重打分;新排序与实际表现排序若在 ≥4/5 样本上不一致,升级被拒;升级必须经跨模型独立审核。完整规范:[shared-references/bump-validation-protocol.md](shared-references/bump-validation-protocol.md)。
3. **rubric 是工作台,不是博物馆**:被新数据推翻或被吸收为正式维度的观察,**删掉**。绝不留"我曾经以为 X,但其实..."的考古层。git history 才是档案。完整规范:[shared-references/observation-lifecycle.md](shared-references/observation-lifecycle.md)。
---
## 路由表(触发词 → 子 skill)
| 用户说 | 调用 | 前置条件 |
|---|---|---|
| "初始化" / "init" / "首次使用" | `/cheat-init` | 无(这是入口) |
| "找对标" / "学这个账号" / "拆这几个对标视频" / "learn from" / "导入对标账号" | `/cheat-learn-from` | 已 initcold-start 强烈建议;后续可随时 --append / --replace |
| "找选题" / "我不知道拍什么" / "seed" / "找前 5 个选题" | `/cheat-seed` | 已 initcold-start 用户专用一次性种子动作) |
| "打分这篇 [path]" / "score this [path]" | `/cheat-score` | rubric_notes.md 存在 |
| "启动预测" / "start prediction" / "给这稿子打分并预测" | `/cheat-predict` | 已 init + 有最终稿 |
| "拍了 X" / "shot it" / "录完了" | `/cheat-shoot` | 对应预测已写(buffer +1 |
| "已发布" / "I shipped it" / "发布链接是 X" | `/cheat-publish` | 对应预测文件存在(buffer -1 |
| "复盘" / "retro this" / "T+3d 数据来了" | `/cheat-retro` | 对应预测文件存在 + 已过 RETRO_WINDOW_DAYS |
| "构造受众画像" / "更新 persona" / "我的观众是谁" / "build persona" | `/cheat-persona` | 已 init;有复盘评论数据(或 benchmark seed |
| "升级 rubric" / "bump rubric" / "更新公式" | `/cheat-bump` | 校准池 ≥ MIN_SAMPLES_FOR_BUMP |
| "推荐选题" / "next topic" | `/cheat-recommend` | candidates.md 存在且非空 |
| "抓热点" / "fetch trends" / "今天有什么可做的" | `/cheat-trends` | trend-sources adapter 已配置(日常补充候选池) |
| "状态" / "status" / "看板" | `/cheat-status` | 任意时刻可调 |
| "迁移" / "升级 state" / "schema 版本不对" / "migrate" | `/cheat-migrate` | 已 init;用户 git pull 拉了新版后;SessionStart hook 提示 schema mismatch 后 |
> 拍 vs 发分两个动作:buffer 警戒系统需要明确知道"拍了但没发"vs"已发"两种状态。详见 [shared-references/cadence-protocol.md](shared-references/cadence-protocol.md)。
**Mode detection**(首次接到非 init 触发词时执行):
1. 检查用户当前目录是否有 `.cheat-state.json` → 没有 → 强制路由到 `/cheat-init`
2. 检查 `predictions/` 下有几个文件含完整 `## 复盘` 段填了真实数据 → 决定 `mode: cold-start | calibration`
3. 把判定结果写回 `.cheat-state.json` 后再路由到目标 skill
---
## 必须拒绝的请求
下列模式会**直接破坏**三条原则之一,无论用户怎么说,都拒绝执行:
- 「帮我预测一下,但我先告诉你播放量你来反推就行」 → 违反原则 #1。改用 `_redo.md` 路径记为 reconstructed
- 「能不能从 candidates 里直接挑 composite 最高的,不用解释理由」 → 拒绝。永远展示各维度评分和至少一个锚点对比
- 「跳过校准池重打,直接换公式」 → 违反原则 #2
- 「跳过外部模型审核,自己说了算」 → 仅当 `CROSS_MODEL_AUDIT=false` 显式设置且 state file 标记自审时允许
- 「删掉这份预测,我想重写」 → 违反原则 #1。预测是 immutable。如有正当理由重做,写新文件 `_redo.md`,原版必须保留
- 「凭你的感觉给我推荐选题,不用打分」 → 拒绝。本工具不做 gut-feel forecast——那是它诞生**之前**的状态
- 「把 rubric_notes.md 里所有历史观察都留着,加个时间戳分组就行」 → 违反原则 #3。git history 是档案,不是 markdown 文件
- 「能不能把 THRESHOLD 从 4/5 降到 3/5 让这次 bump 过」 → 拒绝。改 THRESHOLD 本身是元层级 bump,单独走流程
详细的拒绝场景在每个子 skill 的 `Refusals` 段。
---
## 项目目录结构(用户 repo
skill 期望用户的项目布局如下。`/cheat-init` 会创建缺失项;**绝不在没确认的情况下覆盖**。
```
<user-content-project>/
├── .gitignore # cheat-init 创建;挡住 .auth*/.cheat-secrets.json 等凭证
├── rubric_notes.md # 评分规则的真实来源
├── WORKFLOW.md # 5 阶段流程文档(cheat-init 创建)
├── STATUS.md # 看板(cheat-status 维护)
├── .cheat-state.json # 状态文件,子 skill 共享上下文
├── .cheat-cache/ # 不入版本控制
│ ├── usage.jsonl # 钩子被动记录的使用日志
│ └── trends-history.jsonl # cheat-trends 的去重缓存
├── .claude/
│ └── settings.json # 含 prediction-immutability hook
├── benchmark.md # 对标账号信息(cheat-learn-from 维护)
├── audience.md # 受众画像(cheat-persona 派生;blind 硬禁读)
├── scripts/ # 拍前的所有草稿(cheat-seed 写或用户写)
│ └── YYYY-MM-DD_<id>_<short>.md
├── predictions/ # immutable 预测日志(hook 保护)
│ └── YYYY-MM-DD_<id>_<short>.md # 与 scripts/ 同 id
├── videos/ # 拍后才建(cheat-shoot 创建)
│ └── YYYY-MM-DD_<id>_<short>/
│ ├── script.md # 用户提供的最终拍摄稿(cheat-shoot 时询问"和 scripts/ 一致吗"
│ └── report.md # T+3d 抓的数据 + 评论(cheat-retro 写)
├── samples/ # 对标账号视频 / 转录(cheat-learn-from 创建)
│ └── <账号名>/<video-id>/{source.mp4 (可选), transcript.md, meta.md}
├── candidates.md # 选题池(可选)
└── content.db # 可选 SQLite,校准池规模化后启用
```
---
## 文件清单
### 本 skill 包
```
cheat-on-content/
├── SKILL.md # 本文件(总协议 + 路由)
├── README.md # 营销门面
├── skills/ # 子 skill 集
│ ├── cheat-init/SKILL.md # ✅ 入口:onboarding 与脚手架
│ ├── cheat-learn-from/SKILL.md # ✅ 对标账号导入(拆 pattern + 派生 base rubric 信号)
│ ├── cheat-seed/SKILL.md # ✅ Cold-start 选题启动器(brainstorm + 可选 draft
│ ├── cheat-score/SKILL.md # ✅ 单稿打分(不写文件)
│ ├── cheat-predict/SKILL.md # ✅ 盲预测 + immutable 日志
│ ├── cheat-shoot/SKILL.md # ✅ 登记拍摄(buffer +1
│ ├── cheat-publish/SKILL.md # ✅ 发布元数据登记(buffer -1)
│ ├── cheat-retro/SKILL.md # ✅ 数据回收 + 复盘
│ ├── cheat-persona/SKILL.md # ✅ 受众画像派生(从复盘评论聚类)
│ ├── cheat-bump/SKILL.md # ✅ rubric 升级(含跨模型审)
│ ├── cheat-recommend/SKILL.md # ✅ 候选池排序推荐(按 buffer 颜色 + 1 稳 + 1 实验)
│ ├── cheat-trends/SKILL.md # ✅ 热点抓取(日常补充候选池,多 adapter)
│ ├── cheat-status/SKILL.md # ✅ 状态看板(含 buffer 警戒)
│ ├── cheat-migrate/SKILL.md # ✅ schema 升级(老用户 git pull 后用)
│ └── cheat-score-blind/SKILL.md # ✅ Channel B 隔离打分 sub-agent(仅 Task tool 调用)
├── migrations/ # schema 演进单一来源
│ ├── registry.md # ✅ LATEST_SCHEMA + 版本链表
│ └── <from>-to-<to>.md # ✅ 每步迁移的 WHAT/WHY/HOW/Manual fallback
├── shared-references/ # 跨 skill 共享协议
│ ├── blind-prediction-protocol.md # ✅ 原则 #1
│ ├── bump-validation-protocol.md # ✅ 原则 #2
│ ├── observation-lifecycle.md # ✅ 原则 #3
│ ├── prediction-anatomy.md # ✅ 一份合格预测的 7 个组件
│ ├── candidate-schema.md # ✅ 候选项统一 schema
│ ├── cadence-protocol.md # ✅ 节奏协议(buffer 警戒 + 选题策略)
│ ├── state-management.md # ✅ .cheat-state.json 读写约定
│ └── migration-protocol.md # ✅ schema 演进哲学 + maintainer checklist
├── starter-rubrics/ # 各内容形态的先验 rubric
│ ├── opinion-video.md # ✅ 观点视频(中文,已校准 25+ 样本)
│ ├── opinion-video-zero.md # ✅ v0 等权占位(cold-start
│ ├── long-form-essay.md # ⬜ 公众号 / Substack
│ └── short-form-text.md # ⬜ X thread / 微博长文
├── templates/ # skill 写进用户 repo 的文件骨架
│ ├── gitignore.template # ✅ 用户项目 .gitignore(护凭证,保留 predictions/ 入库)
│ ├── rubric_notes.template.md # ✅
│ ├── prediction.template.md # ✅ 统一版(所有阶段,含 confidence header
│ ├── retro.template.md # ✅
│ ├── candidates.template.md # ✅
│ ├── candidates.template.json # ✅
│ ├── script_patterns.template.md # ✅ 写作 pattern 沉淀(含 benchmark 借鉴段说明)
│ ├── benchmark.template.md # ✅ 对标账号 reference
│ ├── audience.template.md # ✅ 受众画像骨架
│ ├── workflow.template.md # ✅
│ ├── status.template.md # ✅
│ └── content.db.schema.sql # ✅
├── hooks/ # harness 强制层
│ ├── prediction-immutability.json # ✅ 阻塞型钩子(拦预测段编辑)
│ ├── prediction-immutability.sh # ✅ 拦截脚本
│ ├── session-start.json # ✅ SessionStart 自动报告 hook
│ ├── session-start.sh # ✅ 状态报告渲染脚本
│ ├── meta-logging.json # ✅ 被动记录配置
│ └── log-event.sh # ✅ meta-logging 脚本
├── tools/ # 独立 CLI 脚本
│ ├── score-curve.py # ⬜ 预测精度收敛曲线
│ ├── md-to-sqlite.py # ⬜ markdown → content.db 升级(批次 3
│ └── validate-bump.py # ⬜ 校准池全量重打(批次 3)
├── adapters/ # 数据源适配
│ ├── perf-data/ # 复盘数据源(含 douyin-session
│ ├── candidate-pool/ # 候选池数据源
│ ├── trend-sources/ # 热点抓取源
│ └── script-extraction/ # 视频/音频转 script(含 whisper for cheat-learn-from
└── examples/
├── reference-implementation/ # 视频分析脱敏快照(待)
└── script_patterns.example.md # script_patterns 全填示例(参考用,不复制)
```
✅ = 当前批次(v1 骨架)已完成 / ⬜ = 后续批次
---
## Tone & voice
写面向用户的文案(commit message / 复盘小结等)时,匹配项目的 **直白克制(reflective-irreverent** voice
- 直接说出失败:「composite 8.47 但实际只有 16.8w——rubric 高估了 SR」
- **不要**用模糊措辞软化:「这或许可能在某种程度上暗示...」——别这么写
- Cluely 风格的反叛 hook 只在 README 出现——**不要写进** `rubric_notes.md` 或预测日志
---
## 给开发者:扩展本 skill
- 新增内容形态 → 加 `starter-rubrics/<form>.md`
- 新增热点抓取源 → 加 `adapters/trend-sources/<name>.md`,符合 [candidate-schema.md](shared-references/candidate-schema.md) 输出契约
- 修改原则 → 改 `shared-references/<protocol>.md`,所有引用它的 skill 自动跟进
- 修改路由 → 改本文件的"路由表"段
- 子 skill 内部细节 → 直接改对应 `skills/cheat-*/SKILL.md`
完整开发指南见 README.md。
@@ -0,0 +1,2 @@
*.sh text eol=lf
*.py text eol=lf
@@ -0,0 +1,68 @@
# bilibili-stat — B站 perf-data adapter
回收**自己(或任意)B站视频**的播放数据 + 热门评论,供 `/cheat-retro` 复盘。
`/cheat-retro` 调用:当 `state.data_collection=adapter``Platform: bilibili` 时。
## 为什么它比抖音/小红书 adapter 简单
B站的视频统计(`view`)与评论(`reply`)都是**公开接口**
- 不需要登录(没有 `crawler.py login` 步骤、不碰 `.auth/`
- 不需要 wbi 签名(`view` 接口免签名;评论走 `x/v2/reply` 老接口,按热度 `sort=2`
- 不需要浏览器(纯 `httpx`,无 playwright
所以 **clone 下来装个 `httpx` 就能用**,零配置。
## 安装
```bash
pip install -r requirements.txt
```
(若你的内容项目根有 `.venv`,run.sh 会优先用它;否则用系统 `python3`/`python`。)
## 用法
`/cheat-retro` 会自动按下面的契约调用,一般你不用手动跑:
```bash
bash run.sh <bvid_or_url> <video_folder> [<script_path>]
```
- `<bvid_or_url>``BV1cUoUY9Ecr`,或任意含 BV 号的链接(如 `https://www.bilibili.com/video/BV1cUoUY9Ecr`
- `<video_folder>`:输出目录,`report.md` 会写进这里
- `<script_path>`:可选,原始稿子,会并入 report.md 供复盘 diff
也可直接调底层:
```bash
python review.py video BV1cUoUY9Ecr # 抓数据 → 写 videos/<auto>/report.md
python review.py login # B站无需登录,仅打印说明(接口一致性)
```
## 输出(report.md
与 douyin-session 同格式,含:
- 视频元信息(标题 / UP主 / 发布时间 / 时长 / 链接)
- **播放数据**:播放、点赞、投币、收藏、分享、评论、弹幕,并附派生比率(赞播比 / 投币率 / 收藏率 / 分播比)——B站「三连率」尤其能反映硬核认可度
- 原始稿子(若提供)
- **热门评论**(按点赞降序,带 IP 属地)
## 接口
| 数据 | 接口 |
|---|---|
| 视频统计 | `GET https://api.bilibili.com/x/web-interface/view?bvid=<BV号>` |
| 评论 | `GET https://api.bilibili.com/x/v2/reply?type=1&oid=<aid>&sort=2&pn=&ps=20` |
`oid` 取自 view 接口返回的 `aid`。评论按热度(`sort=2`)翻页,默认取 top 50。
## 退出码
`0` 成功 · `2` 缺依赖(httpx 未装)· `3` 其他失败(网络 / 解析 / BV 号错误)。任何失败时 `/cheat-retro` 会优雅降级到 manual 模式。
## 字段随接口改版的维护
`view``stat` 字段名(view/like/coin/favorite/share/reply/danmaku)和评论的 `replies[].{like,content.message,member.uname,rcount,reply_control.location}` 都已按 2026-06 真实返回校准。若 B站改版导致某项为 0,对照 `crawler.py``fetch_video` / `_normalize_comment` 调整 key 即可。
+135
View File
@@ -0,0 +1,135 @@
"""B站视频数据 + 评论抓取(公开接口,无需登录、无需签名)。
与 douyin-session / xhs-explore 不同:B站的视频统计与评论都是**公开数据**——
`view` 接口不需要 wbi 签名,评论走 `x/v2/reply` 老接口(按热度)。因此本 adapter
是纯 httpx,零登录、零浏览器,clone 下来即可用。
接口:
- 视频数据:https://api.bilibili.com/x/web-interface/view?bvid=<BV号>
- 评论: https://api.bilibili.com/x/v2/reply?type=1&oid=<aid>&sort=2 sort=2 热度)
"""
from __future__ import annotations
import re
import sys
import time
import httpx
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
HEADERS = {"User-Agent": UA, "Referer": "https://www.bilibili.com"}
VIEW_API = "https://api.bilibili.com/x/web-interface/view"
REPLY_API = "https://api.bilibili.com/x/v2/reply"
def normalize_bvid(raw: str) -> str:
"""从 BV 号或 B站视频 URL(含 b23.tv 短链跳转后)里提取 BV 号。"""
m = re.search(r"(BV[0-9A-Za-z]{10})", raw or "")
return m.group(1) if m else (raw or "").strip()
def _client() -> httpx.Client:
return httpx.Client(headers=HEADERS, timeout=20, follow_redirects=True)
def fetch_video(client: httpx.Client, bvid: str) -> dict:
"""拉视频信息 + 统计。返回归一化后的 dict。"""
resp = client.get(VIEW_API, params={"bvid": bvid})
resp.raise_for_status()
j = resp.json()
if j.get("code") != 0:
raise RuntimeError(f"view 接口失败 code={j.get('code')} msg={j.get('message')}")
d = j.get("data") or {}
stat = d.get("stat") or {}
return {
"bvid": d.get("bvid") or bvid,
"aid": d.get("aid"),
"title": d.get("title") or "",
"desc": d.get("desc") or "",
"owner": (d.get("owner") or {}).get("name") or "",
"pubdate": d.get("pubdate") or 0,
"duration_s": d.get("duration") or 0,
"play_count": stat.get("view") or 0,
"like_count": stat.get("like") or 0,
"coin_count": stat.get("coin") or 0,
"favorite_count": stat.get("favorite") or 0,
"share_count": stat.get("share") or 0,
"comment_count": stat.get("reply") or 0,
"danmaku_count": stat.get("danmaku") or 0,
"raw": d,
}
def fetch_comments(client: httpx.Client, aid: int, max_count: int = 50) -> list[dict]:
"""按热度(sort=2)翻页抓评论,最多 max_count 条。"""
out: list[dict] = []
seen: set[str] = set()
pn = 1
for _ in range(60): # 翻页上限保护
if len(out) >= max_count:
break
try:
j = client.get(
REPLY_API,
params={"type": 1, "oid": aid, "sort": 2, "pn": pn, "ps": 20},
).json()
except Exception as exc:
print(f"[警告] 评论请求异常(停止):{exc}")
break
if j.get("code") != 0:
print(f"[警告] 评论接口 code={j.get('code')} msg={j.get('message')}(停止)")
break
reps = (j.get("data") or {}).get("replies") or []
if not reps:
break
for c in reps:
nc = _normalize_comment(c)
if nc["cid"] in seen:
continue
seen.add(nc["cid"])
out.append(nc)
pn += 1
time.sleep(0.4)
out.sort(key=lambda x: x["digg_count"], reverse=True)
return out[:max_count]
def _normalize_comment(c: dict) -> dict:
member = c.get("member") or {}
content = c.get("content") or {}
loc = (c.get("reply_control") or {}).get("location") or ""
return {
"cid": str(c.get("rpid") or ""),
"text": content.get("message") or "",
"digg_count": c.get("like") or 0,
"reply_comment_total": c.get("rcount") or 0,
"create_time": c.get("ctime") or 0,
"user_name": member.get("uname") or "",
"ip_label": loc.replace("IP属地:", "").replace("IP属地:", "").strip() if loc else "",
}
def fetch_all(bvid: str, max_comments: int = 50) -> dict:
"""一次拉完视频数据 + 热门评论。"""
bvid = normalize_bvid(bvid)
with _client() as client:
print(f" → 拉取视频数据 {bvid}")
video = fetch_video(client, bvid)
print(f"{video['title'][:40]}(播放 {video['play_count']}")
print(" → 拉取热门评论")
if video.get("aid") is None:
print("[警告] 视频数据缺少 aid 字段,无法拉取评论")
comments = []
else:
comments = fetch_comments(client, video["aid"], max_count=max_comments)
print(f"{len(comments)} 条评论")
return {"video": video, "comments": comments}
if __name__ == "__main__":
# 与 douyin-session / xhs-explore 的接口保持一致;B站公开数据无需登录。
if len(sys.argv) > 1 and sys.argv[1] == "login":
print("[B站] 视频数据与评论均为公开接口,无需登录,直接 /cheat-retro 即可。")
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
def runtime_project_root(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_PROJECT_ROOT"):
return Path(active_env["CHEAT_PROJECT_ROOT"]).expanduser().resolve()
base_cwd = cwd if cwd is not None else Path.cwd()
return Path(base_cwd).expanduser().resolve()
def debug_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
return runtime_project_root(env=env, cwd=cwd) / ".cheat-cache" / "bilibili-stat-debug"
def videos_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_VIDEOS_DIR"):
return Path(active_env["CHEAT_VIDEOS_DIR"]).expanduser().resolve()
return runtime_project_root(env=env, cwd=cwd) / "videos"
@@ -0,0 +1,93 @@
"""把抓到的 B站数据渲染成 NotebookLM 友好的 Markdown(与 douyin-session 同格式)。"""
from __future__ import annotations
import datetime as dt
from pathlib import Path
def _fmt_time(ts: int) -> str:
if not ts:
return "未知"
return dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
def _fmt_num(n: int | None) -> str:
if n is None:
return "-"
if n >= 10000:
return f"{n / 10000:.1f}w"
return str(n)
def _fmt_duration(s: int) -> str:
if not s:
return "-"
return f"{s // 60}:{s % 60:02d}" if s >= 60 else f"{s}s"
def _ratio(num: int | None, den: int | None) -> str:
if not num or not den:
return "-"
return f"{num / den * 100:.2f}%"
def render_report(video: dict, script: str, comments: list[dict]) -> str:
lines: list[str] = []
title = video.get("title") or "(无标题)"
bvid = video["bvid"]
play = video.get("play_count") or 0
lines.append(f"# {title}")
lines.append("")
lines.append(f"- BV 号:`{bvid}`")
lines.append(f"- UP 主:{video.get('owner') or '-'}")
lines.append(f"- 发布时间:{_fmt_time(video.get('pubdate', 0))}")
lines.append(f"- 时长:{_fmt_duration(video.get('duration_s', 0))}")
lines.append(f"- 链接:https://www.bilibili.com/video/{bvid}")
lines.append(f"- 抓取时间:{dt.datetime.now().strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append("## 播放数据")
lines.append("")
lines.append(f"- 播放:{_fmt_num(play)}")
lines.append(f"- 点赞:{_fmt_num(video.get('like_count'))}(赞播比 {_ratio(video.get('like_count'), play)}")
lines.append(f"- 投币:{_fmt_num(video.get('coin_count'))}(投币率 {_ratio(video.get('coin_count'), play)}")
lines.append(f"- 收藏:{_fmt_num(video.get('favorite_count'))}(收藏率 {_ratio(video.get('favorite_count'), play)}")
lines.append(f"- 分享:{_fmt_num(video.get('share_count'))}(分播比 {_ratio(video.get('share_count'), play)}")
lines.append(f"- 评论:{_fmt_num(video.get('comment_count'))}")
lines.append(f"- 弹幕:{_fmt_num(video.get('danmaku_count'))}")
lines.append("")
lines.append("> 派生比率(赞播比 / 投币率 / 收藏率 / 分播比)是单纯播放量看不出的信号;"
"B站「三连率」(点赞+投币+收藏)尤其能反映内容的硬核认可度。")
lines.append("")
lines.append("## 原始稿子")
lines.append("")
lines.append(script.strip() if script.strip() else "(未提供)")
lines.append("")
lines.append(f"## 评论(按点赞降序,共 {len(comments)} 条)")
lines.append("")
if not comments:
lines.append("(未抓到评论,可能评论区关闭或该视频暂无评论)")
else:
for c in comments:
text = (c.get("text") or "").replace("\n", " ").strip()
reply = f" 💬{c['reply_comment_total']}" if c.get("reply_comment_total") else ""
loc = f" [{c['ip_label']}]" if c.get("ip_label") else ""
lines.append(f"- [👍{c['digg_count']}{reply}]{loc} {text}")
lines.append("")
return "\n".join(lines)
def slugify(text: str, max_len: int = 30) -> str:
bad = '<>:"/\\|?*\n\r\t'
out = "".join("_" if ch in bad else ch for ch in text).strip()
return out[:max_len] or "untitled"
def output_dir_for(video: dict, root: Path) -> Path:
date = _fmt_time(video.get("pubdate", 0))[:10].replace("未知", "nodate")
slug = slugify(video.get("title") or video["bvid"])
return root / f"{date}_{slug}"
@@ -0,0 +1 @@
httpx>=0.27
@@ -0,0 +1,61 @@
"""发完视频后跑一次:抓 B站数据/评论 → 生成 NotebookLM 友好的 md。
用法:
python review.py login # B站无需登录(仅为接口一致)
python review.py video <BV号或视频URL> [script.txt] # 直接指定视频
"""
from __future__ import annotations
import sys
from pathlib import Path
import crawler
import renderer
from paths import videos_dir
def run_with_id(bvid: str, script_path: str | None) -> None:
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
script = ""
if script_path:
p = Path(script_path).expanduser()
if p.is_file():
script = p.read_text(encoding="utf-8", errors="ignore")
print(f"稿子:{p.name}{len(script)} 字符)")
else:
print(f"[警告] 找不到稿子 {p}")
print(f"[抓取] 视频 {bvid}")
result = crawler.fetch_all(bvid)
video = result["video"]
comments = result["comments"]
out_dir = renderer.output_dir_for(video, active_videos_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if script:
(out_dir / "script.txt").write_text(script, encoding="utf-8")
md = renderer.render_report(video, script, comments)
report = out_dir / "report.md"
report.write_text(md, encoding="utf-8")
print(f"\n{report}")
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "login":
print("[B站] 视频数据与评论均为公开接口,无需登录,直接复盘即可。")
return
if len(sys.argv) > 1 and sys.argv[1] == "video":
if len(sys.argv) < 3:
print("用法:python review.py video <BV号或视频URL> [script.txt]")
sys.exit(3)
bvid = sys.argv[2]
script_path = sys.argv[3] if len(sys.argv) > 3 else None
run_with_id(bvid, script_path)
return
print(__doc__)
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# bilibili-stat adapter wrapper
#
# Called by /cheat-retro when state.data_collection=adapter and platform=bilibili.
#
# Usage:
# bash run.sh <bvid_or_url> <video_folder> [<script_path>]
#
# Example:
# bash run.sh BV1cUoUY9Ecr ~/my-channel/videos/2026-05-04_BV1cUoUY9Ecr_AI接入MC
#
# B站视频数据(view)与评论(reply)都是公开接口——无需登录、无需 wbi 签名、无需浏览器。
# 纯 httpx,因此这个 adapter 没有 `crawler.py login` 步骤,clone 下来配好依赖即可用。
#
# Output: writes report.md INTO the video_folder.
# Exit codes:
# 0 = success (report.md written)
# 2 = adapter dependency missing (httpx not installed)
# 3 = other failure (network, parse error, bad bvid, etc.)
set -uo pipefail
BVID="${1:-}"
VIDEO_FOLDER="${2:-}"
SCRIPT_PATH="${3:-}"
if [[ -z "$BVID" || -z "$VIDEO_FOLDER" ]]; then
echo "Usage: bash run.sh <bvid_or_url> <video_folder> [<script_path>]" >&2
exit 3
fi
# Resolve adapter source dir (where this script lives)
ADAPTER_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Find Python — prefer venv in user's project root if exists
PYTHON=""
# Walk up from VIDEO_FOLDER to find project root (.cheat-state.json)
PROJECT_ROOT="$( realpath "$VIDEO_FOLDER" )"
while [[ "$PROJECT_ROOT" != "/" && ! -f "$PROJECT_ROOT/.cheat-state.json" ]]; do
PROJECT_ROOT="$( dirname "$PROJECT_ROOT" )"
done
if [[ ! -f "$PROJECT_ROOT/.cheat-state.json" ]]; then
echo "❌ Cannot find project root (.cheat-state.json) from $VIDEO_FOLDER" >&2
exit 3
fi
if [[ -x "$PROJECT_ROOT/.venv/bin/python" ]]; then
PYTHON="$PROJECT_ROOT/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PYTHON="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON="python"
else
echo "❌ python not found — install Python 3.10+ first" >&2
exit 2
fi
# Verify httpx is installed
if ! "$PYTHON" -c "import httpx" 2>/dev/null; then
cat >&2 <<EOF
❌ httpx not installed.
Install:
pip install -r "$ADAPTER_DIR/requirements.txt"
Then re-run /cheat-retro.
EOF
exit 2
fi
# Make sure video_folder exists
mkdir -p "$VIDEO_FOLDER"
# Resolve script path (optional)
SCRIPT_ARG=""
if [[ -n "$SCRIPT_PATH" && -f "$SCRIPT_PATH" ]]; then
SCRIPT_ARG="$SCRIPT_PATH"
fi
# Run from PROJECT_ROOT so outputs go to expected paths; override videos dir to user's
cd "$PROJECT_ROOT"
export CHEAT_PROJECT_ROOT="$PROJECT_ROOT"
export CHEAT_VIDEOS_DIR="$( dirname "$VIDEO_FOLDER" )" # = user's videos/
echo "[bilibili-stat] fetching $BVID into $VIDEO_FOLDER"
if [[ -n "$SCRIPT_ARG" ]]; then
"$PYTHON" "$ADAPTER_DIR/review.py" video "$BVID" "$SCRIPT_ARG"
else
"$PYTHON" "$ADAPTER_DIR/review.py" video "$BVID"
fi
# review.py writes to CHEAT_VIDEOS_DIR/<auto-named-folder>/report.md (named by title).
# Move it into our canonical video_folder if names differ.
LATEST_REPORT=$(find "$( dirname "$VIDEO_FOLDER" )" -name "report.md" -newer "$VIDEO_FOLDER" -type f 2>/dev/null | head -1)
if [[ -n "$LATEST_REPORT" && "$( dirname "$LATEST_REPORT" )" != "$VIDEO_FOLDER" ]]; then
cp "$LATEST_REPORT" "$VIDEO_FOLDER/report.md"
AUTO_DIR=$( dirname "$LATEST_REPORT" )
if [[ -f "$AUTO_DIR/script.txt" ]]; then
cp "$AUTO_DIR/script.txt" "$VIDEO_FOLDER/script.txt"
fi
echo "[bilibili-stat] moved auto-named output to $VIDEO_FOLDER/"
fi
if [[ ! -f "$VIDEO_FOLDER/report.md" ]]; then
echo "❌ report.md not produced — see review.py output above for details" >&2
exit 3
fi
echo "✅ report.md written to $VIDEO_FOLDER/report.md"
exit 0
+122
View File
@@ -0,0 +1,122 @@
# Adapter: douyin-session(抖音爬取)
`/cheat-retro``state.data_collection=adapter` + `platform=douyin` 时自动调用。
> **来源**:照搬自参考博主项目(私有)的 `crawler.py` / `review.py` / `renderer.py`——已在中文观点视频博主账号上跑了 25+ 视频验证。
---
## 这个 adapter 是干嘛的
抖音视频页是**强 JS 渲染 + XHR 加载评论 + 反爬严重**——不带浏览器引擎和登录态的方案(比如 Claude 的 WebFetch、Python requests)拿不到任何数据。
douyin-session 用 **Playwright + 持久化 Chromium context** 模拟真实浏览器:
- 你首次扫码登录抖音创作者中心,cookie 存在**你的内容项目根目录** `.auth/`
- 之后每次抓取直接复用 cookie,不用重新登录
- 拦截抖音前端的 XHR responses 直接抓数据接口的 JSON(不解析 HTML)
- 抓 3 类数据:视频列表 / 详细数据(完播 / 转粉率)/ 评论
输出写到**你的内容项目** `videos/<...>/report.md``cheat-retro` 读这个文件 → 摘要写到 prediction 复盘段)。
调试产物(URL dump / 截图)写到 `.cheat-cache/douyin-session-debug/`,避免散落在 skill 源码目录。
## 安装(一次性)
```bash
# 1. 进你的内容项目根目录
cd ~/my-channel
# 2. 建虚拟环境(强烈建议——Playwright + Chromium 几百 MB,别污染 system Python
python3 -m venv .venv
source .venv/bin/activate
# 3. 装 Playwright
pip install playwright>=1.44
# 4. 装 Chromium(首次必须)
playwright install chromium
# 5. 首次扫码登录抖音创作者中心
ADAPTER=$(find ~/.claude/skills -name "douyin-session" -type d 2>/dev/null | head -1)
# 如找不到(可能 adapter 不在全局),用源码路径:
# ADAPTER="$HOME/Desktop/cheat-test/cheat-on-content/adapters/perf-data/douyin-session"
python "$ADAPTER/crawler.py" login
# → 弹出 Chromium 窗口,扫码登录创作者中心
# → 登录成功后窗口自动关闭,cookie 存在 当前目录/.auth/
```
## 用法
cheat-retro 自动调用,你不需要手动跑。但如果想手动测试:
```bash
cd ~/my-channel
source .venv/bin/activate
# 列最近视频(看登录态有没有失效)
python "$ADAPTER/crawler.py" list
# 抓特定视频
python "$ADAPTER/review.py" video <aweme_id> <video_folder>/script.md
# 输出在 当前目录/videos/<日期>_<title>/report.md
```
## 怎么拿到 aweme_id
抖音视频 URL 形态:
- `https://www.douyin.com/video/7234567890123456789``aweme_id = 7234567890123456789`(直接在 URL 路径里)
- `https://v.douyin.com/abc123` → 短链需要 resolvecheat-publish 自动做)
cheat-publish 会在登记发布时把 aweme_id 存到 prediction header(如能 resolve)。cheat-retro 启动时直接读这个字段。
## report.md 输出格式
`renderer.py` 生成。包含:
- 视频元信息(标题、发布时间、时长)
- 数据快照(播放、点赞、评论、转发、收藏 + 派生比率:赞播比 / 评播比 / 分播比)
- 完播率 / 3s 留存(如能抓到)
- Top 20 评论(按赞数排序,含评论文本 + 赞数)
- 评论关键词聚类(renderer 自动做,可选)
## 失败模式(按概率从高到低)
| 症状 | 原因 | 处理 |
|---|---|---|
| `ensure_login` 超时 | cookie 过期或抖音强制 reauth | 重新跑 `python crawler.py login` |
| `_parse_video_list` 返回空 | 抖音改了接口字段——结构性变化 | 看 `.debug/creator_urls.txt` 抓到的 URL,更新 crawler.py 的字段兜底 |
| `_parse_video_list` 视频列表不全 | 翻页没抓到——网络慢或反爬触发 | 调高 `crawler.py` 里的 `await asyncio.sleep(...)` 时长 |
| Chromium 崩溃 / 卡死 | 通常是机器内存不足 | 关闭其他 Chromium 进程;`playwright install chromium --force` 重装 |
| 评论抓取慢(>5min) | 评论页 XHR 多次滚动触发——慢网络 | 调小 `fetch_comments_creator``max_pages`,或换更稳的网络 |
**关键现实**:抖音接口结构每隔几个月会变一次。这个 adapter **需要持续维护**——如果有一天它突然失败,第一步是去看 `视频分析` 项目最新的 crawler.py 看有没有新版本。
## 稳定性等级
★★ — Playwright 方案能扛比纯 HTTP 强得多的反爬,但仍受抖音前端改版影响。建议每月手动跑一次 `crawler.py list` 验证健康。
## 风险提示
- **冷启动用户慎装**Playwright + Chromium 体积大(~500MB),新人容易劝退
- **TOS 风险**:用自己的 cookie 抓自己后台数据是个人用途;别滥用
- **不要把 .auth/ 提交到 git**:cookie 里有你的会话凭据,泄露 = 他人能登录你的抖音账号
- `.cheat-cache/douyin-session-debug/` 也不应提交到 git:里面可能含调试截图和接口 URL dump
## 文件清单
```
adapters/perf-data/douyin-session/
├── README.md # 本文件
├── requirements.txt # playwright>=1.44
├── crawler.py # 抓取核心(视频列表 / 详细数据 / 评论)
├── review.py # CLI 入口(交互式或 video <aweme_id> 模式)
├── renderer.py # 把抓回的 JSON 渲染成 report.md
└── run.sh # cheat-retro 调用的 wrapper
```
## 与其他 adapter 的关系
- `youtube-data-api`(待)— YouTube 用官方 API,不需要 Playwright,更轻
- `bilibili-stat`(待)— B 站官方 stat 接口公开,也不需要 Playwright
- `xhs-explore` — 小红书,已实现(同样走 Playwright 被动拦截路线,照搬自本 adapter)
如果你做多平台内容,**只装你实际用的 adapter**——不需要全装。
@@ -0,0 +1,415 @@
"""抖音创作者中心 + 前台评论抓取。
登录一次后,Cookie 持久化在 .auth/,之后直接复用。
一次抓取共享一个 Chromium 会话,稳定性优于每步一个进程。
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from playwright.async_api import BrowserContext, Page, Response, async_playwright
from paths import auth_dir, debug_dir
CREATOR_HOME = "https://creator.douyin.com/creator-micro/home"
CREATOR_CONTENT = "https://creator.douyin.com/creator-micro/content/manage"
class Session:
"""单浏览器会话,按顺序跑多步抓取。"""
def __init__(self, ctx: BrowserContext, pw: Any) -> None:
self.ctx = ctx
self.pw = pw
@classmethod
async def open(cls, headless: bool = False) -> Session:
pw = await async_playwright().start()
auth_path = auth_dir()
auth_path.mkdir(exist_ok=True)
ctx = await pw.chromium.launch_persistent_context(
user_data_dir=str(auth_path),
headless=headless,
viewport={"width": 1440, "height": 900},
args=["--disable-blink-features=AutomationControlled"],
)
return cls(ctx, pw)
async def close(self) -> None:
try:
await self.ctx.close()
finally:
await self.pw.stop()
async def ensure_login(timeout_s: int = 300) -> bool:
"""扫码登录;检测到 sessionid 后自动关闭。"""
sess = await Session.open()
try:
page = await sess.ctx.new_page()
await page.goto(CREATOR_HOME)
print(f"[登录] 在弹出的 Chromium 窗口里扫码。最多等 {timeout_s} 秒……")
for i in range(timeout_s):
try:
cookies = await sess.ctx.cookies("https://creator.douyin.com")
has_session = any(c["name"] in ("sessionid", "sessionid_ss") for c in cookies)
if has_session and "login" not in page.url:
print(f"[登录] ✓ 检测到登录态(用时 {i}s")
await asyncio.sleep(1)
return True
except Exception:
pass
await asyncio.sleep(1)
print("[登录] 超时未检测到登录态。")
return False
finally:
await sess.close()
async def fetch_recent_videos(sess: Session, limit: int = 50) -> list[dict]:
"""从创作者中心拉最近视频列表。"""
captured: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
if any(k in resp.url for k in (
"/janus/douyin/creator/pc/work_list",
"/aweme/v1/creator/item/list",
)):
try:
data = await resp.json()
captured.append({"url": resp.url, "data": data})
if len(captured) == 1 and isinstance(data, dict):
print(f"[诊断] 视频接口 keys: {list(data.keys())[:8]}")
except Exception:
pass
page.on("response", on_response)
try:
await page.goto(CREATOR_CONTENT, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(8)
# 翻页加载更多
for _ in range(3):
await page.evaluate("window.scrollBy(0, 1200)")
await asyncio.sleep(1.5)
videos = _parse_video_list(captured, limit)
if not videos:
debug_path = debug_dir()
debug_path.mkdir(parents=True, exist_ok=True)
(debug_path / "creator_urls.txt").write_text("\n".join(all_urls), encoding="utf-8")
print(f"[诊断] 视频列表为空,{len(all_urls)} 个请求已 dump。")
return videos
finally:
await page.close()
def _parse_video_list(captured: list[dict], limit: int) -> list[dict]:
videos: list[dict] = []
for item in captured:
data = item["data"]
candidates: list = []
if isinstance(data, dict):
for key in ("aweme_list", "item_list", "items", "list"):
if key in data and isinstance(data[key], list):
candidates = data[key]
break
if not candidates and isinstance(data.get("data"), dict):
for key in ("aweme_list", "item_list", "items", "list"):
if key in data["data"] and isinstance(data["data"][key], list):
candidates = data["data"][key]
break
for v in candidates:
videos.append(_normalize_video(v))
# 去重(按 aweme_id
seen = set()
dedup = []
for v in videos:
if v["aweme_id"] in seen:
continue
seen.add(v["aweme_id"])
dedup.append(v)
return dedup[:limit]
def _pick(primary: dict, fallback: dict, key: str, default=0):
v = primary.get(key)
if v is not None:
return v
v = fallback.get(key)
if v is not None:
return v
return default
def _normalize_video(v: dict) -> dict:
aweme_id = v.get("aweme_id") or v.get("item_id") or v.get("id") or ""
stats = v.get("statistics") or v.get("stats") or {}
video_info = v.get("video") or {}
return {
"aweme_id": str(aweme_id),
"desc": v.get("desc") or v.get("title") or "",
"create_time": v.get("create_time") or v.get("createTime") or 0,
"duration_ms": video_info.get("duration") or v.get("duration") or 0,
"play_count": _pick(stats, v, "play_count"),
"digg_count": _pick(stats, v, "digg_count"),
"comment_count": _pick(stats, v, "comment_count"),
"share_count": _pick(stats, v, "share_count"),
"collect_count": _pick(stats, v, "collect_count"),
"raw": v,
}
async def fetch_video_detail(sess: Session, aweme_id: str) -> dict:
"""视频数据分析页(完播、转粉等)。"""
captured: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
if any(k in resp.url for k in (
"data_center", "data_external", "aweme_statistic",
"item_detail", "statistics", "/data/",
)):
try:
data = await resp.json()
captured.append({"url": resp.url, "data": data})
except Exception:
pass
page.on("response", on_response)
try:
url = f"https://creator.douyin.com/creator-micro/data/following/media?item_id={aweme_id}"
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(6)
if not captured:
debug_path = debug_dir()
debug_path.mkdir(parents=True, exist_ok=True)
(debug_path / "detail_urls.txt").write_text("\n".join(all_urls), encoding="utf-8")
print("[诊断] 详细数据接口没拦到,已 dump URL 列表。")
return {"captured": captured}
finally:
await page.close()
async def fetch_comments_creator(sess: Session, aweme_id: str, max_pages: int = 60) -> list[dict]:
"""走创作者中心『评论管理』页面。登录了创作者中心就能用,比前台稳定。"""
captured: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
# 创作者中心评论接口路径多变,宽松匹配
if "comment" in resp.url and "creator" in resp.url:
try:
data = await resp.json()
captured.append({"url": resp.url, "data": data})
except Exception:
pass
elif "/aweme/v1/web/comment/list/" in resp.url:
try:
data = await resp.json()
captured.append({"url": resp.url, "data": data})
except Exception:
pass
page.on("response", on_response)
try:
# 多个候选页面,哪个能用就用哪个
urls_to_try = [
f"https://creator.douyin.com/creator-micro/content/comment-manage?item_id={aweme_id}",
f"https://creator.douyin.com/creator-micro/content/comment-manage",
]
for u in urls_to_try:
try:
await page.goto(u, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(4)
break
except Exception as e:
print(f"[警告] {u} 加载失败: {e}")
# 如果评论管理页面有 item_id 过滤,应该已经只显示这个视频的评论
# 滚动加载分页
for i in range(max_pages):
await page.evaluate("window.scrollBy(0, 1500)")
await asyncio.sleep(1.5)
debug_path = debug_dir()
debug_path.mkdir(parents=True, exist_ok=True)
await page.screenshot(path=str(debug_path / "creator_comment_page.png"))
(debug_path / "creator_comment_urls.txt").write_text("\n".join(all_urls), encoding="utf-8")
# 解析:抽取所有 data 里可能包含评论的结构
comments: list[dict] = []
for item in captured:
data = item["data"]
for key in ("comments", "comment_list", "data"):
if isinstance(data, dict) and key in data:
val = data[key]
if isinstance(val, list):
for c in val:
if isinstance(c, dict) and ("text" in c or "content" in c):
comments.append(_normalize_comment_creator(c, aweme_id))
# 去重
seen = set()
dedup = []
for c in comments:
if c["cid"] in seen or c.get("aweme_id") and str(c["aweme_id"]) != str(aweme_id):
continue
seen.add(c["cid"])
dedup.append(c)
dedup.sort(key=lambda x: x["digg_count"], reverse=True)
print(f" 创作者页共 {len(dedup)} 条评论")
return dedup
finally:
await page.close()
def _normalize_comment_creator(c: dict, default_aweme_id: str) -> dict:
"""创作者中心评论字段(和前台不完全一样)。"""
user = c.get("user") or c.get("user_info") or {}
return {
"cid": str(c.get("cid") or c.get("comment_id") or c.get("id") or ""),
"aweme_id": str(c.get("aweme_id") or c.get("item_id") or default_aweme_id),
"text": c.get("text") or c.get("content") or "",
"digg_count": c.get("digg_count") or c.get("like_count") or 0,
"reply_comment_total": c.get("reply_comment_total") or c.get("reply_count") or 0,
"create_time": c.get("create_time") or 0,
"user_name": user.get("nickname") or user.get("name") or c.get("user_name") or "",
"ip_label": c.get("ip_label") or c.get("ip_location") or "",
}
async def fetch_comments(sess: Session, aweme_id: str, max_pages: int = 60) -> list[dict]:
"""前台视频页 → 点击评论图标 → 滚动加载 → 拦截 comment/list XHR。"""
all_comments: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
if "/aweme/v1/web/comment/list/" in resp.url:
try:
data = await resp.json()
for c in data.get("comments") or []:
all_comments.append(_normalize_comment(c))
except Exception:
pass
page.on("response", on_response)
try:
url = f"https://www.douyin.com/video/{aweme_id}"
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
except Exception as e:
print(f"[警告] 视频页加载异常:{e}")
await asyncio.sleep(6)
# 点击 "video-comment-more" 把评论展开(这是"展开评论区"按钮)
for sel in ['[data-e2e="video-comment-more"]', '[data-e2e="feed-comment-icon"]']:
try:
await page.locator(sel).first.click(force=True, timeout=3000)
print(f" 已点击 {sel}")
break
except Exception:
pass
await asyncio.sleep(4)
debug_path = debug_dir()
debug_path.mkdir(parents=True, exist_ok=True)
await page.screenshot(path=str(debug_path / "comment_page.png"))
# 策略:把最后一条评论 scrollIntoView 触发 IntersectionObserver 懒加载
last_count = 0
stagnant = 0
i = 0
for i in range(max_pages):
await page.evaluate(
"""() => {
const items = document.querySelectorAll(
'[data-e2e="comment-item"], [data-e2e^="comment-item"], .comment-item'
);
if (items.length > 0) {
items[items.length - 1].scrollIntoView({block: 'end', behavior: 'instant'});
} else {
// 评论容器还没渲染出 item,滚页面
window.scrollBy(0, 1500);
}
}"""
)
await asyncio.sleep(2.2)
cur = len({c["cid"] for c in all_comments})
if cur == last_count:
stagnant += 1
if stagnant >= 6:
break
else:
stagnant = 0
last_count = cur
print(f" 滚动 {i+1} 次后退出,已累计 {last_count}")
# 去重 + 排序
seen = set()
dedup = []
for c in all_comments:
if c["cid"] in seen:
continue
seen.add(c["cid"])
dedup.append(c)
dedup.sort(key=lambda x: x["digg_count"], reverse=True)
(debug_path / "comment_urls.txt").write_text("\n".join(all_urls), encoding="utf-8")
return dedup
finally:
await page.close()
def _normalize_comment(c: dict) -> dict:
user = c.get("user") or {}
return {
"cid": str(c.get("cid") or c.get("id") or id(c)),
"text": c.get("text") or "",
"digg_count": c.get("digg_count") or 0,
"reply_comment_total": c.get("reply_comment_total") or 0,
"create_time": c.get("create_time") or 0,
"user_name": user.get("nickname") or "",
"ip_label": c.get("ip_label") or "",
}
async def fetch_all(aweme_id: str) -> dict:
"""一个会话跑完视频列表 + 详细数据 + 评论。"""
sess = await Session.open()
try:
print(" → 打开创作者中心,拉视频列表")
videos = await fetch_recent_videos(sess, limit=50)
video = next((v for v in videos if v["aweme_id"] == aweme_id), None)
if not video:
print(f" 未在最近 {len(videos)} 条里找到 {aweme_id},用最小元数据继续。")
video = _normalize_video({"aweme_id": aweme_id})
else:
print(f"{video.get('desc', '')[:40]}")
print(" → 打开数据分析页")
detail = await fetch_video_detail(sess, aweme_id)
print(" → 打开前台视频页抓评论")
comments = await fetch_comments(sess, aweme_id, max_pages=60)
print(f" 最终 {len(comments)}")
return {"video": video, "detail": detail, "comments": comments}
finally:
await sess.close()
if __name__ == "__main__":
asyncio.run(ensure_login())
@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
def runtime_project_root(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_PROJECT_ROOT"):
return Path(active_env["CHEAT_PROJECT_ROOT"]).expanduser().resolve()
base_cwd = cwd if cwd is not None else Path.cwd()
return Path(base_cwd).expanduser().resolve()
def auth_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
return runtime_project_root(env=env, cwd=cwd) / ".auth"
def debug_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
return runtime_project_root(env=env, cwd=cwd) / ".cheat-cache" / "douyin-session-debug"
def videos_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_VIDEOS_DIR"):
return Path(active_env["CHEAT_VIDEOS_DIR"]).expanduser().resolve()
return runtime_project_root(env=env, cwd=cwd) / "videos"
@@ -0,0 +1,100 @@
"""把抓到的数据渲染成 NotebookLM 友好的 Markdown。"""
from __future__ import annotations
import datetime as dt
from pathlib import Path
def _fmt_time(ts: int) -> str:
if not ts:
return "未知"
return dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
def _fmt_num(n: int | None) -> str:
if n is None:
return "-"
if n >= 10000:
return f"{n/10000:.1f}w"
return str(n)
def _fmt_duration(ms: int) -> str:
if not ms:
return "-"
s = ms // 1000
return f"{s//60}:{s%60:02d}" if s >= 60 else f"{s}s"
def render_report(
video: dict,
script: str,
comments: list[dict],
detail_captured: list[dict] | None = None,
) -> str:
lines: list[str] = []
desc = video.get("desc") or "(无标题)"
aweme_id = video["aweme_id"]
lines.append(f"# {desc}")
lines.append("")
lines.append(f"- 视频 ID`{aweme_id}`")
lines.append(f"- 发布时间:{_fmt_time(video.get('create_time', 0))}")
lines.append(f"- 时长:{_fmt_duration(video.get('duration_ms', 0))}")
lines.append(f"- 链接:https://www.douyin.com/video/{aweme_id}")
lines.append(f"- 抓取时间:{dt.datetime.now().strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append("## 播放数据")
lines.append("")
lines.append(f"- 播放:{_fmt_num(video.get('play_count'))}")
lines.append(f"- 点赞:{_fmt_num(video.get('digg_count'))}")
lines.append(f"- 评论:{_fmt_num(video.get('comment_count'))}")
lines.append(f"- 收藏:{_fmt_num(video.get('collect_count'))}")
lines.append(f"- 分享:{_fmt_num(video.get('share_count'))}")
lines.append("")
if detail_captured:
lines.append("### 详细指标(来自创作者中心)")
lines.append("")
lines.append("```json")
import json
for item in detail_captured[:3]:
full = json.dumps(item["data"], ensure_ascii=False, indent=2)
truncated = full[:2000]
if len(full) > 2000:
truncated += "\n... (truncated)"
lines.append(truncated)
lines.append("```")
lines.append("")
lines.append("## 原始稿子")
lines.append("")
lines.append(script.strip() if script.strip() else "(未提供)")
lines.append("")
lines.append(f"## 评论(按点赞降序,共 {len(comments)} 条)")
lines.append("")
if not comments:
lines.append("(未抓到评论,可能评论区被折叠或账号未登录)")
else:
for c in comments:
text = c["text"].replace("\n", " ").strip()
reply = f" 💬{c['reply_comment_total']}" if c.get("reply_comment_total") else ""
lines.append(f"- [👍{c['digg_count']}{reply}] {text}")
lines.append("")
return "\n".join(lines)
def slugify(text: str, max_len: int = 30) -> str:
"""生成文件夹友好的短标题。"""
bad = '<>:"/\\|?*\n\r\t'
out = "".join("_" if ch in bad else ch for ch in text).strip()
return out[:max_len] or "untitled"
def output_dir_for(video: dict, root: Path) -> Path:
date = _fmt_time(video.get("create_time", 0))[:10].replace("未知", "nodate")
slug = slugify(video.get("desc") or video["aweme_id"])
return root / f"{date}_{slug}"
@@ -0,0 +1 @@
playwright>=1.44
+145
View File
@@ -0,0 +1,145 @@
"""发完视频后跑一次:抓评论/数据 → 生成 NotebookLM 友好的 md。
用法:
python review.py # 交互式选视频
python review.py login # 仅登录(首次)
python review.py video <aweme_id> [script.txt] # 直接指定视频
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import crawler
import renderer
from paths import videos_dir
def _prompt(msg: str) -> str:
try:
return input(msg).strip()
except EOFError:
return ""
def _pick_video(videos: list[dict]) -> dict | None:
if not videos:
print("未抓到视频列表。请确认创作者中心已登录,或页面结构已变,需要更新 crawler。")
return None
print("\n最近视频:")
for i, v in enumerate(videos):
t = renderer._fmt_time(v.get("create_time", 0))
desc = (v.get("desc") or "").replace("\n", " ")[:40]
print(f" [{i}] {t} | 播放 {renderer._fmt_num(v.get('play_count'))} | {desc}")
choice = _prompt("\n选择序号(回车取消):")
if not choice.isdigit():
return None
idx = int(choice)
if 0 <= idx < len(videos):
return videos[idx]
return None
def _resolve_script(raw: str) -> str:
"""允许直接拖拽文件到终端;兼容 macOS 转义空格。"""
p = raw.strip().strip("'").strip('"').replace("\\ ", " ")
if not p:
return ""
path = Path(p).expanduser()
if path.is_file():
return path.read_text(encoding="utf-8", errors="ignore")
print(f"[警告] 找不到文件 {path},稿子留空。")
return ""
async def run() -> None:
"""交互式:先列最近 10 条,用户选一个,再让用户拖稿子,最后抓取。
注意:会打开两次 Chromium(一次选视频,一次抓全量)。
"""
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
print("[选视频] 打开创作者中心拉列表……")
sess = await crawler.Session.open()
try:
videos = await crawler.fetch_recent_videos(sess, limit=10)
finally:
await sess.close()
video = _pick_video(videos)
if not video:
print("已取消。")
return
script_raw = _prompt("把稿子 txt 拖进来(或回车跳过):")
script_path: str | None = None
if script_raw.strip():
p = Path(script_raw.strip().strip("'").strip('"').replace("\\ ", " ")).expanduser()
if p.is_file():
script_path = str(p)
else:
print(f"[警告] 找不到 {p},稿子留空。")
await run_with_id(video["aweme_id"], script_path)
async def run_with_id(aweme_id: str, script_path: str | None) -> None:
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
script = ""
if script_path:
p = Path(script_path).expanduser()
if p.is_file():
script = p.read_text(encoding="utf-8", errors="ignore")
print(f"稿子:{p.name}{len(script)} 字符)")
else:
print(f"[警告] 找不到稿子 {p}")
print(f"[抓取] 视频 {aweme_id}")
result = await crawler.fetch_all(aweme_id)
video = result["video"]
detail = result["detail"]
comments = result["comments"]
out_dir = renderer.output_dir_for(video, active_videos_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if script:
(out_dir / "script.txt").write_text(script, encoding="utf-8")
md = renderer.render_report(video, script, comments, detail.get("captured"))
report = out_dir / "report.md"
report.write_text(md, encoding="utf-8")
print(f"\n{report}")
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "login":
asyncio.run(crawler.ensure_login())
return
if len(sys.argv) > 1 and sys.argv[1] == "video":
if len(sys.argv) < 3:
print("用法:python review.py video <aweme_id> [script.txt]")
sys.exit(2)
aweme_id = sys.argv[2]
script_path = sys.argv[3] if len(sys.argv) > 3 else None
asyncio.run(run_with_id(aweme_id, script_path))
return
if len(sys.argv) > 1 and sys.argv[1] == "list":
async def _list() -> None:
sess = await crawler.Session.open()
try:
videos = await crawler.fetch_recent_videos(sess, limit=20)
finally:
await sess.close()
for i, v in enumerate(videos):
t = renderer._fmt_time(v.get("create_time", 0))
desc = (v.get("desc") or "").replace("\n", " ")[:50]
print(f"[{i}] {v['aweme_id']} {t} 播放{renderer._fmt_num(v.get('play_count'))} {desc}")
asyncio.run(_list())
return
asyncio.run(run())
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
#
# douyin-session adapter wrapper
#
# Called by /cheat-retro when state.data_collection=adapter and platform=douyin.
#
# Usage:
# bash run.sh <aweme_id> <video_folder> [<script_path>]
#
# Example:
# bash run.sh 7234567890123456789 ~/my-channel/videos/2026-05-04_abc123_停止期待
#
# Output: writes report.md INTO the video_folder.
# Exit codes:
# 0 = success (report.md written)
# 1 = login expired or required
# 2 = adapter dependency missing (playwright not installed)
# 3 = other failure (network, parse error, etc.)
set -uo pipefail
AWEME_ID="${1:-}"
VIDEO_FOLDER="${2:-}"
SCRIPT_PATH="${3:-}"
if [[ -z "$AWEME_ID" || -z "$VIDEO_FOLDER" ]]; then
echo "Usage: bash run.sh <aweme_id> <video_folder> [<script_path>]" >&2
exit 3
fi
# Resolve adapter source dir (where this script lives)
ADAPTER_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Find Python — prefer venv in user's project root if exists
PYTHON=""
# Walk up from VIDEO_FOLDER to find project root (.cheat-state.json)
PROJECT_ROOT="$( realpath "$VIDEO_FOLDER" )"
while [[ "$PROJECT_ROOT" != "/" && ! -f "$PROJECT_ROOT/.cheat-state.json" ]]; do
PROJECT_ROOT="$( dirname "$PROJECT_ROOT" )"
done
if [[ ! -f "$PROJECT_ROOT/.cheat-state.json" ]]; then
echo "❌ Cannot find project root (.cheat-state.json) from $VIDEO_FOLDER" >&2
exit 3
fi
if [[ -x "$PROJECT_ROOT/.venv/bin/python" ]]; then
PYTHON="$PROJECT_ROOT/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PYTHON="python3"
else
echo "❌ python3 not found — install Python 3.10+ first" >&2
exit 2
fi
# Verify playwright is installed
if ! "$PYTHON" -c "import playwright" 2>/dev/null; then
cat >&2 <<EOF
❌ playwright not installed.
Install in your project venv:
cd "$PROJECT_ROOT"
python3 -m venv .venv
source .venv/bin/activate
pip install -r "$ADAPTER_DIR/requirements.txt"
playwright install chromium
Then re-run /cheat-retro.
EOF
exit 2
fi
# Verify auth/ exists in project root (cookie persistence)
if [[ ! -d "$PROJECT_ROOT/.auth" ]]; then
cat >&2 <<EOF
❌ Not logged in to 抖音 创作者中心.
First-time login (one-shot):
cd "$PROJECT_ROOT"
source .venv/bin/activate
$PYTHON "$ADAPTER_DIR/crawler.py" login
A Chromium window will pop up — scan QR with your phone to log in.
Cookie will be saved to .auth/ for future runs.
EOF
exit 1
fi
# Make sure video_folder exists
mkdir -p "$VIDEO_FOLDER"
# Resolve script path (optional — if given, copy to video_folder/script.txt for renderer)
SCRIPT_ARG=""
if [[ -n "$SCRIPT_PATH" && -f "$SCRIPT_PATH" ]]; then
SCRIPT_ARG="$SCRIPT_PATH"
fi
# Run from PROJECT_ROOT so .auth/ is found and outputs go to expected paths
cd "$PROJECT_ROOT"
export CHEAT_PROJECT_ROOT="$PROJECT_ROOT"
# Override VIDEOS_DIR via env var so review.py writes to user's videos/ not its own
# (review.py uses ROOT/videos by default; we override to use user's project)
export CHEAT_VIDEOS_DIR="$( dirname "$VIDEO_FOLDER" )" # = user's videos/
echo "[douyin-session] fetching aweme_id=$AWEME_ID into $VIDEO_FOLDER"
if [[ -n "$SCRIPT_ARG" ]]; then
"$PYTHON" "$ADAPTER_DIR/review.py" video "$AWEME_ID" "$SCRIPT_ARG"
else
"$PYTHON" "$ADAPTER_DIR/review.py" video "$AWEME_ID"
fi
# review.py writes to ROOT/videos/<auto-named-folder>/report.md.
# We need to find the just-written report.md and move it to our video_folder if names differ.
# (review.py uses video.title for folder name; ours uses <date>_<id>_<short>.)
LATEST_REPORT=$(find "$( dirname "$VIDEO_FOLDER" )" -name "report.md" -newer "$VIDEO_FOLDER" -type f 2>/dev/null | head -1)
if [[ -n "$LATEST_REPORT" && "$( dirname "$LATEST_REPORT" )" != "$VIDEO_FOLDER" ]]; then
# Move the autonamed folder's report into our canonical folder
cp "$LATEST_REPORT" "$VIDEO_FOLDER/report.md"
AUTO_DIR=$( dirname "$LATEST_REPORT" )
# Optionally also copy script.txt if review.py wrote it
if [[ -f "$AUTO_DIR/script.txt" ]]; then
cp "$AUTO_DIR/script.txt" "$VIDEO_FOLDER/script.txt"
fi
# Clean up the auto-generated directory to avoid duplicates
rm -rf "$AUTO_DIR"
echo "[douyin-session] moved auto-named output and cleaned up $AUTO_DIR"
fi
if [[ ! -f "$VIDEO_FOLDER/report.md" ]]; then
echo "❌ report.md not produced — see review.py output above for details" >&2
exit 3
fi
echo "✅ report.md written to $VIDEO_FOLDER/report.md"
exit 0
@@ -0,0 +1,2 @@
*.sh text eol=lf
*.py text eol=lf
@@ -0,0 +1,151 @@
# Adapter: linkedin-sessionLinkedIn 单帖分析爬取)
`/cheat-retro``state.data_collection=adapter` + `platform=linkedin` 时自动调用。
> **来源**:架构照搬 `douyin-session`Playwright 持久化登录态 + 读渲染后 DOM)。
> 单帖分析(impressions / reach / reactions / …)已在真实 LinkedIn 账号上端到端验证。
---
## 这个 adapter 是干嘛的
LinkedIn 单帖分析页(`/analytics/post-summary/`)**只对帖子作者本人可见**、且数据是
SSR/inline 进页面的——没有公开接口、没有稳定可拦的 voyager XHR,纯 HTTP / requests
(包括 Claude 的 WebFetch)拿不到任何数据。
linkedin-session 用 **Playwright + 持久化 Chromium context** 模拟真实浏览器:
- 你首次登录 LinkedIn(拿到 `li_at` cookie),cookie 存在**你的内容项目根目录** `.auth-linkedin/`
- 之后每次抓取直接复用 cookie,不用重新登录
- 导航到单帖分析页 → 读渲染后 DOM 文本 → 按已知标签锚点解析(**不逆向、不伪造请求**)
- 抓单帖的 10 个指标:展示 / 触达 / 反应 / 评论 / 转发 / 收藏 / 私信转发 / 社交互动 /
帖子带来的主页访问 / 帖子带来的新增关注,外加帖子正文
输出写到**你的内容项目** `videos/<...>/report.md``cheat-retro` 读这个文件 → 摘要写到 prediction 复盘段)。
调试产物(DOM 文本 dump)写到 `.cheat-cache/linkedin-session-debug/`,避免散落在 skill 源码目录。
## 一个诚实的维护说明:LinkedIn 会随机切换 日/英
LinkedIn 同一账号、同一 session 内会在**日文界面**和**英文界面**之间随机切换
(标签会从 `Impressions` 变成 `インプレッション数`)。所以 `extract.py``POST_METRICS`
**每个指标都存两套别名**,解析时两套都试。如果你的界面是第三种语言,照着 `POST_METRICS`
把对应标签补进去即可(多语言别名是 list,加一项不影响已有的)。
## 安装(一次性)
```bash
# 1. 进你的内容项目根目录
cd ~/my-channel
# 2. 建虚拟环境(强烈建议——Playwright + Chromium 几百 MB,别污染 system Python
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. 装 Playwright
pip install -r "$ADAPTER/requirements.txt"
# 4. 装 Chromium(首次必须)
playwright install chromium
# 5. 首次登录 LinkedIn
ADAPTER=$(find ~/cheat-on-content -name "linkedin-session" -type d 2>/dev/null | head -1)
python "$ADAPTER/crawler.py" login
# → 弹出 Chromium 窗口,登录 LinkedIn
# → 登录成功后窗口自动关闭,cookie(li_at) 存在 当前目录/.auth-linkedin/
```
> 提示:adapter 在 `~/cheat-on-content/adapters/perf-data/linkedin-session`(克隆源码处),
> 不在 `~/.claude/skills`install.sh 只复制 skill,不复制 adapter)。
## 用法
cheat-retro 自动调用,你不需要手动跑。手动测试:
```bash
cd ~/my-channel
source .venv/bin/activate
# 抓特定帖子(给 activity_id 或整条帖子链接都行)
python "$ADAPTER/review.py" video 7470493738918920193 <video_folder>/script.md
# 输出在 当前目录/videos/<日期>_<activity_id>_<作者>/report.md
```
run.sh 是 cheat-retro 调用的 wrapper
```bash
bash run.sh <activity_id_or_url> <video_folder> [<script_path>]
```
## 怎么拿到 activity_id
LinkedIn 帖子 URL 形态:
- `https://www.linkedin.com/feed/update/urn:li:activity:7470493738918920193/``activity_id = 7470493738918920193`
- 分析页 `https://www.linkedin.com/analytics/post-summary/urn:li:activity:7470493738918920193/` → 同上
adapter 会从整条链接里自动提取 activity_id(也接受直接给裸 id)。
cheat-publish 登记发布时把 activity_id 存到 prediction headercheat-retro 启动时读这个字段。
## report.md 输出格式
`renderer.py` 生成,与 douyin-session 同形。包含:
- 帖子元信息(作者、发布距今、链接、抓取时间)
- 数据快照(展示 / 触达 / 反应 / 评论 / 转发 / 收藏 / 私信转发 / 社交互动 /
帖子带来的主页访问 / 帖子带来的新增关注 + 派生比率:反应率 / 评论率 / 转发率 / 社交互动率)
- 帖子正文(从分析页 DOM 抽到时)
- 原始稿子(cheat-retro 传入)
- 评论(**LinkedIn 单帖分析页只给评论数、不给评论正文**——report.md 会标注,建议手动粘 top 评论)
## 失败模式(按概率从高到低)
| 症状 | 原因 | 处理 |
|---|---|---|
| `ensure_login` 超时 | cookie 过期或 LinkedIn 强制 reauth | 重新跑 `python crawler.py login` |
| 抓取被重定向到登录页 | `li_at` 失效 | 重新跑 `python crawler.py login` |
| `impressions` 为 None / 指标缺失 | LinkedIn 改了版式或换了第三种语言 | 看 `.cheat-cache/linkedin-session-debug/post_<id>.txt`,把新标签加进 `extract.py``POST_METRICS` |
| 看不到分析数据 | 该帖**不是你本人**发的(单帖分析仅作者可见) | 只能抓自己的帖子 |
| 正文没抓到 | 分析页 DOM 偶尔不含完整正文 | report.md 会标注;手动补正文 |
| Chromium 崩溃 / 卡死 | 通常是机器内存不足 | 关其他 Chromium;`playwright install chromium --force` 重装 |
**关键现实**:LinkedIn 没有给个人创作者的公开数据 API,单帖分析只能靠登录态读页面。
版式和界面语言都可能变——这个 adapter **需要持续维护**,第一步永远是看 debug 目录里的
`post_<id>.txt` 对照标签。
## 稳定性等级
★★ — Playwright + 登录态能拿到纯 HTTP 拿不到的数据,但解析依赖 DOM 文本版式 +
界面语言(日/英随机),比走 JSON 接口的 adapter 略脆。建议每月手动跑一次验证健康。
## 风险提示
- **冷启动用户慎装**Playwright + Chromium ~500MB,新人容易劝退
- **TOS 风险**:用自己的 cookie 抓自己后台数据是个人用途;别滥用、别高频
- **不要把 `.auth-linkedin/` 提交到 git**cookie(`li_at`) 等同你的 LinkedIn 会话凭据,
泄露 = 他人能登录你的 LinkedIn 账号
- `.cheat-cache/linkedin-session-debug/` 也不应提交(里面是页面 DOM 文本 dump,可能含个人信息)
## 文件清单
```
adapters/perf-data/linkedin-session/
├── README.md # 本文件
├── requirements.txt # playwright>=1.44
├── crawler.py # 抓取核心(登录 + 单帖分析页 DOM 抓取)
├── extract.py # 纯函数 DOM 解析(双语 日/英 标签)
├── renderer.py # 把抓回的数据渲染成 report.md
├── review.py # CLI 入口(login / video <activity_id> [script]
├── paths.py # 项目根 / .auth-linkedin / debug 路径解析
├── test_extract.py # extract.py 单元测试(合成样本,含双语)
├── .gitattributes # *.sh / *.py eol=lf(防 Windows-CRLF 破坏脚本)
└── run.sh # cheat-retro 调用的 wrapper
```
## 与其他 adapter 的关系
- `douyin-session` — 抖音,本 adapter 的架构来源
- `xhs-explore` — 小红书,同走 Playwright 被动路线
- `bilibili-stat` — B 站官方 stat 公开接口,最轻
如果你做多平台内容,**只装你实际用的 adapter**——不需要全装。
@@ -0,0 +1,140 @@
"""LinkedIn 单帖分析抓取。
登录一次后,Cookie(含 `li_at`)持久化在 .auth-linkedin/,之后直接复用。
LinkedIn 把单帖分析(/analytics/post-summary/SSR/inline 进页面,没有稳定可拦的
voyager XHR,所以读渲染后 DOM 文本、按已知标签锚点解析(见 extract.py)。
和 douyin-session 一样:一次抓取共享一个持久化 Chromium 会话。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from playwright.async_api import BrowserContext, Page, async_playwright
from paths import auth_dir, debug_dir
from extract import parse_post_meta, parse_post_summary
FEED = "https://www.linkedin.com/feed/"
POST_SUMMARY = "https://www.linkedin.com/analytics/post-summary/urn:li:activity:{activity_id}/"
_ACTIVITY_RE = re.compile(r"urn:li:activity:(\d+)|activity[:-](\d+)|/(\d{15,25})\b")
def extract_activity_id(raw: str) -> str:
"""从帖子 URL 或裸 id 提取 activity_id。
支持:
- 裸 id`7470493738918920193`
- 帖子链接:`https://www.linkedin.com/feed/update/urn:li:activity:7470493738918920193/`
- 分析链接:`https://www.linkedin.com/analytics/post-summary/urn:li:activity:7470493738918920193/`
抽不出时原样返回(交给上层报错)。
"""
raw = raw.strip()
if raw.isdigit():
return raw
m = _ACTIVITY_RE.search(raw)
if m:
return next(g for g in m.groups() if g)
return raw
class Session:
"""单浏览器会话,持久化登录态。"""
def __init__(self, ctx: BrowserContext, pw: Any) -> None:
self.ctx = ctx
self.pw = pw
@classmethod
async def open(cls, headless: bool = False) -> "Session":
pw = await async_playwright().start()
auth_path = auth_dir()
auth_path.mkdir(parents=True, exist_ok=True)
ctx = await pw.chromium.launch_persistent_context(
user_data_dir=str(auth_path),
headless=headless,
viewport={"width": 1440, "height": 900},
args=["--disable-blink-features=AutomationControlled"],
)
return cls(ctx, pw)
async def close(self) -> None:
try:
await self.ctx.close()
finally:
await self.pw.stop()
async def _logged_in(ctx: BrowserContext) -> bool:
cookies = await ctx.cookies("https://www.linkedin.com")
return any(c["name"] == "li_at" for c in cookies)
async def ensure_login(timeout_s: int = 300) -> bool:
"""打开 LinkedIn,等用户登录;检测到 li_at cookie 后返回。"""
sess = await Session.open()
try:
page = await sess.ctx.new_page()
await page.goto(FEED)
print(f"[登录] 在弹出的 Chromium 里登录 LinkedIn。最多等 {timeout_s} 秒……")
for i in range(timeout_s):
if await _logged_in(sess.ctx):
print(f"[登录] ✓ 检测到 li_at(用时 {i}s)。Cookie 已存到 .auth-linkedin/")
await asyncio.sleep(1)
return True
await asyncio.sleep(1)
print("[登录] 超时未检测到登录态。")
return False
finally:
await sess.close()
async def _scrape_post(page: Page, activity_id: str) -> dict:
await page.goto(
POST_SUMMARY.format(activity_id=activity_id),
wait_until="domcontentloaded",
timeout=60000,
)
await asyncio.sleep(7)
if "post-summary" not in page.url:
print(f"[post] ⚠ 可能被登出 / 重定向:{page.url}")
txt = await page.inner_text("body")
dbg = debug_dir()
dbg.mkdir(parents=True, exist_ok=True)
(dbg / f"post_{activity_id}.txt").write_text(txt, encoding="utf-8")
result = parse_post_summary(txt)
result["meta"] = parse_post_meta(txt)
result["activity_id"] = activity_id
if result["metrics"].get("impressions") is None:
print(f"[post] ⚠ {activity_id} 没抽到 impressions(结构可能变了,看 {dbg}/post_{activity_id}.txt")
return result
async def fetch_post_summary(activity_id: str, headless: bool = True) -> dict:
"""DOM 抽取单帖分析(/analytics/post-summary/)。只能看**你自己**的帖子。"""
sess = await Session.open(headless=headless)
try:
if not await _logged_in(sess.ctx):
print("[post] 未登录。先跑:python review.py login")
return {}
page = await sess.ctx.new_page()
return await _scrape_post(page, activity_id)
finally:
await sess.close()
async def fetch_all(activity_id: str, headless: bool = True) -> dict:
"""抓单帖分析,返回 {'post': {...}}(与 review.py 的渲染入口对齐)。"""
print(f" → 打开单帖分析页 activity:{activity_id}")
post = await fetch_post_summary(activity_id, headless=headless)
if post:
m = post.get("metrics", {})
print(f" impressions={m.get('impressions')} reactions={m.get('reactions')} comments={m.get('comments')}")
return {"post": post}
if __name__ == "__main__":
asyncio.run(ensure_login())
@@ -0,0 +1,113 @@
"""从渲染后的 DOM 文本里抽取 LinkedIn 单帖分析指标。
LinkedIn 把单帖分析(/analytics/post-summary/SSR/inline 进页面——不是可拦的
voyager XHR,所以读 `inner_text` 文本、按已知标签锚点解析。纯函数,可独立测试
(见 test_extract.py)。
LinkedIn 会在**日文 / 英文之间随机切换**界面语言(同一 session 内都可能换),
所以每个指标都存多语言别名,两套都试。
"""
from __future__ import annotations
import re
def _to_int(s: str) -> int | None:
"""'34,057' → 34057'1.2K' → 1200'3M' → 3000000;抽不出返回 None。"""
s = s.strip().replace(",", "")
m = re.fullmatch(r"([\d.]+)\s*([KMB]?)", s)
if not m:
return None
mult = {"": 1, "K": 1_000, "M": 1_000_000, "B": 1_000_000_000}[m.group(2)]
try:
return int(float(m.group(1)) * mult)
except ValueError:
return None
# 单帖分析页(/analytics/post-summary/)。LinkedIn 在 日/英 间**随机切换**语言,
# 所以每个指标存多语言别名。版式两段:
# 顶部指标——值在标签上一行("before"
# 互动明细——值在标签下一行("after"
# (labels, key, value_position)
POST_METRICS = [
(("インプレッション数", "Impressions"), "impressions", "before"),
(("リーチしたメンバー", "Members reached"), "reach", "before"),
(("この投稿からのプロフィール閲覧ユーザー", "Profile viewers from this post"), "profile_views_from_post", "before"),
(("この投稿で獲得したフォロワー", "Followers gained from this post"), "followers_from_post", "before"),
(("ソーシャルエンゲージメント", "Social engagements"), "social_engagement", "before"),
(("リアクション", "Reactions"), "reactions", "after"),
(("コメント", "Comments"), "comments", "after"),
(("再投稿", "Reposts"), "reposts", "after"),
(("保存数", "Saves"), "saves", "after"),
(("LinkedInでの送信数", "Sends on LinkedIn"), "sends", "after"),
]
_IMPRESSION_LABELS = ("インプレッション数", "Impressions")
# 作者署名行的语言标记("…さんが投稿しました • 4日" / "… posted this • 6d")。
_BYLINE_MARKERS = ("さんが投稿しました", "posted this")
# 顶部指标段之前会出现的小标题("Discovery" / "調査" 等)——正文与指标的分界。
_BODY_END_MARKERS = ("調査", "Discovery", "ディスカバリー")
def parse_post_summary(text: str) -> dict:
"""单帖分析 DOM 文本 → {'metrics': {...}}。支持 日/英(LinkedIn 随机切换)。
锚在第一个指标(Impressions)后,避开正文里的数字;每个指标按 value_position
取前一行 / 后一行。抽不到的指标为 None(接口/版式变更时不致整体崩)。
"""
lines = [l.strip() for l in text.splitlines() if l.strip()]
start = 0
for i, l in enumerate(lines):
if l in _IMPRESSION_LABELS:
start = max(0, i - 1)
break
scan = lines[start:]
out = {key: None for _, key, _ in POST_METRICS}
for i, line in enumerate(scan):
for labels, key, pos in POST_METRICS:
if line not in labels or out[key] is not None:
continue
j = i - 1 if pos == "before" else i + 1
if 0 <= j < len(scan):
out[key] = _to_int(scan[j])
return {"metrics": out}
def parse_post_meta(text: str) -> dict:
"""从单帖分析 DOM 抽作者署名 + 相对发布时间 + 正文。
版式(reading order):署名行("…さんが投稿しました • 4日" / "… posted this • 6d"
→ 正文若干行 → 顶部指标小标题("Discovery"/"調査")→ 指标段。
取署名行与小标题之间的行为正文;抽不到时各字段为空,不报错。
返回 {'author': str, 'age': str, 'text': str}。
"""
lines = [l.strip() for l in text.splitlines() if l.strip()]
byline_idx = None
for i, l in enumerate(lines):
if any(m in l for m in _BYLINE_MARKERS):
byline_idx = i
break
if byline_idx is None:
return {"author": "", "age": "", "text": ""}
byline = lines[byline_idx]
author, age = "", ""
if "" in byline:
head, _, tail = byline.partition("")
age = tail.strip()
author = head.strip()
else:
author = byline.strip()
for marker in _BYLINE_MARKERS:
if marker in author:
author = author.split(marker, 1)[0].strip()
break
body: list[str] = []
for l in lines[byline_idx + 1:]:
if l in _IMPRESSION_LABELS or l in _BODY_END_MARKERS:
break
body.append(l)
return {"author": author, "age": age, "text": "\n".join(body).strip()}
@@ -0,0 +1,41 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
def runtime_project_root(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_PROJECT_ROOT"):
return Path(active_env["CHEAT_PROJECT_ROOT"]).expanduser().resolve()
base_cwd = cwd if cwd is not None else Path.cwd()
return Path(base_cwd).expanduser().resolve()
def auth_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
# 独立于抖音 .auth/ 与小红书 .auth-xhs/,避免多 adapter 共用 project root 时 cookie 串味
return runtime_project_root(env=env, cwd=cwd) / ".auth-linkedin"
def debug_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
return runtime_project_root(env=env, cwd=cwd) / ".cheat-cache" / "linkedin-session-debug"
def videos_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_VIDEOS_DIR"):
return Path(active_env["CHEAT_VIDEOS_DIR"]).expanduser().resolve()
return runtime_project_root(env=env, cwd=cwd) / "videos"
@@ -0,0 +1,105 @@
"""把抓到的 LinkedIn 单帖分析渲染成 NotebookLM 友好的 Markdown(与 douyin-session 同形)。"""
from __future__ import annotations
import datetime as dt
from pathlib import Path
def _fmt_num(n: int | None) -> str:
if n is None:
return "-"
return f"{n:,}"
def _ratio(num: int | None, den: int | None) -> str:
"""派生比率,分母为 0 / 缺失时显示 '-'"""
if not num or not den:
return "-"
return f"{num / den * 100:.2f}%"
def render_report(post: dict, script: str) -> str:
metrics = post.get("metrics", {}) if post else {}
meta = post.get("meta", {}) if post else {}
activity_id = post.get("activity_id", "") if post else ""
impressions = metrics.get("impressions")
reactions = metrics.get("reactions")
comments = metrics.get("comments")
reposts = metrics.get("reposts")
author = meta.get("author") or ""
title = author and f"{author} 的 LinkedIn 帖子" or f"LinkedIn 帖子 {activity_id}"
lines: list[str] = []
lines.append(f"# {title}")
lines.append("")
lines.append(f"- 帖子 activity_id`{activity_id}`")
if author:
lines.append(f"- 作者:{author}")
if meta.get("age"):
lines.append(f"- 发布距今:{meta['age']}")
lines.append(f"- 链接:https://www.linkedin.com/feed/update/urn:li:activity:{activity_id}/")
lines.append(f"- 抓取时间:{dt.datetime.now().strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append("## 数据快照")
lines.append("")
lines.append(f"- 展示(Impressions):{_fmt_num(impressions)}")
lines.append(f"- 触达人数(Members reached):{_fmt_num(metrics.get('reach'))}")
lines.append(f"- 社交互动(Social engagements):{_fmt_num(metrics.get('social_engagement'))}")
lines.append(f"- 点赞 / 反应(Reactions):{_fmt_num(reactions)}")
lines.append(f"- 评论(Comments):{_fmt_num(comments)}")
lines.append(f"- 转发(Reposts):{_fmt_num(reposts)}")
lines.append(f"- 收藏(Saves):{_fmt_num(metrics.get('saves'))}")
lines.append(f"- 私信转发(Sends):{_fmt_num(metrics.get('sends'))}")
lines.append(f"- 帖子带来的主页访问(Profile viewers from post):{_fmt_num(metrics.get('profile_views_from_post'))}")
lines.append(f"- 帖子带来的新增关注(Followers from post):{_fmt_num(metrics.get('followers_from_post'))}")
lines.append("")
lines.append("派生比率(相对展示数):")
lines.append(f"- 反应率:{_ratio(reactions, impressions)}")
lines.append(f"- 评论率:{_ratio(comments, impressions)}")
lines.append(f"- 转发率:{_ratio(reposts, impressions)}")
lines.append(f"- 社交互动率:{_ratio(metrics.get('social_engagement'), impressions)}")
lines.append("")
lines.append("## 帖子正文")
lines.append("")
body = (meta.get("text") or "").strip()
lines.append(body if body else "(未抓到正文——单帖分析页有时不含完整正文,可手动补)")
lines.append("")
lines.append("## 原始稿子")
lines.append("")
lines.append(script.strip() if script.strip() else "(未提供)")
lines.append("")
lines.append("## 评论")
lines.append("")
if comments:
lines.append(
f"LinkedIn 单帖分析页只给评论**数**({comments} 条),不含评论正文。"
)
lines.append(
"评论文本是真信号——建议手动把 top 评论粘到这一节,供复盘分析。"
)
else:
lines.append("(没有评论,或未抓到评论数)")
lines.append("")
return "\n".join(lines)
def slugify(text: str, max_len: int = 30) -> str:
"""生成文件夹友好的短标题。"""
bad = '<>:"/\\|?*\n\r\t'
out = "".join("_" if ch in bad else ch for ch in text).strip()
return out[:max_len] or "untitled"
def output_dir_for(post: dict, root: Path) -> Path:
activity_id = post.get("activity_id", "") if post else ""
date = dt.datetime.now().strftime("%Y-%m-%d")
author = (post.get("meta", {}) or {}).get("author") if post else ""
slug = slugify(author or activity_id or "linkedin")
return root / f"{date}_{activity_id}_{slug}".rstrip("_")
@@ -0,0 +1 @@
playwright>=1.44
@@ -0,0 +1,69 @@
"""发完帖子后跑一次:抓 LinkedIn 单帖分析 → 生成 NotebookLM 友好的 md。
用法:
python review.py login # 仅登录(首次,弹出浏览器)
python review.py video <activity_id_or_url> [script.txt] # 抓单帖分析
`video` 子命令名沿用 douyin-session / bilibili-stat 的契约(/cheat-retro 统一调
`run.sh ... video ...`);对 LinkedIn 而言"video"即一条帖子。
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import crawler
import renderer
from paths import videos_dir
def run_with_id(activity_raw: str, script_path: str | None) -> None:
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
activity_id = crawler.extract_activity_id(activity_raw)
script = ""
if script_path:
p = Path(script_path).expanduser()
if p.is_file():
script = p.read_text(encoding="utf-8", errors="ignore")
print(f"稿子:{p.name}{len(script)} 字符)")
else:
print(f"[警告] 找不到稿子 {p}")
print(f"[抓取] 帖子 activity:{activity_id}")
result = asyncio.run(crawler.fetch_all(activity_id))
post = result["post"]
if not post:
print("❌ 未抓到数据(多半是未登录)。先跑:python review.py login")
sys.exit(3)
out_dir = renderer.output_dir_for(post, active_videos_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if script:
(out_dir / "script.txt").write_text(script, encoding="utf-8")
md = renderer.render_report(post, script)
report = out_dir / "report.md"
report.write_text(md, encoding="utf-8")
print(f"\n{report}")
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "login":
asyncio.run(crawler.ensure_login())
return
if len(sys.argv) > 1 and sys.argv[1] == "video":
if len(sys.argv) < 3:
print("用法:python review.py video <activity_id_or_url> [script.txt]")
sys.exit(3)
activity_raw = sys.argv[2]
script_path = sys.argv[3] if len(sys.argv) > 3 else None
run_with_id(activity_raw, script_path)
return
print(__doc__)
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
#
# linkedin-session adapter wrapper
#
# Called by /cheat-retro when state.data_collection=adapter and platform=linkedin.
#
# Usage:
# bash run.sh <activity_id_or_url> <video_folder> [<script_path>]
#
# Example:
# bash run.sh 7470493738918920193 ~/my-channel/videos/2026-05-04_7470493738918920193_post
# bash run.sh https://www.linkedin.com/feed/update/urn:li:activity:7470493738918920193/ <folder>
#
# Output: writes report.md INTO the video_folder.
# Exit codes:
# 0 = success (report.md written)
# 1 = login expired or required (no .auth-linkedin/)
# 2 = adapter dependency missing (playwright not installed)
# 3 = other failure (network, parse error, bad activity id, etc.)
set -uo pipefail
ACTIVITY_ID="${1:-}"
VIDEO_FOLDER="${2:-}"
SCRIPT_PATH="${3:-}"
if [[ -z "$ACTIVITY_ID" || -z "$VIDEO_FOLDER" ]]; then
echo "Usage: bash run.sh <activity_id_or_url> <video_folder> [<script_path>]" >&2
exit 3
fi
# Resolve adapter source dir (where this script lives)
ADAPTER_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Find Python — prefer venv in user's project root if exists
PYTHON=""
PROJECT_ROOT="$( dirname "$( dirname "$( realpath "$VIDEO_FOLDER" )" )" )"
if [[ -x "$PROJECT_ROOT/.venv/bin/python" ]]; then
PYTHON="$PROJECT_ROOT/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PYTHON="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON="python"
else
echo "❌ python not found — install Python 3.10+ first" >&2
exit 2
fi
# Verify playwright is installed
if ! "$PYTHON" -c "import playwright" 2>/dev/null; then
cat >&2 <<EOF
❌ playwright not installed.
Install in your project venv:
cd "$PROJECT_ROOT"
python3 -m venv .venv
source .venv/bin/activate
pip install -r "$ADAPTER_DIR/requirements.txt"
playwright install chromium
Then re-run /cheat-retro.
EOF
exit 2
fi
# Verify .auth-linkedin/ exists in project root (cookie persistence)
if [[ ! -d "$PROJECT_ROOT/.auth-linkedin" ]]; then
cat >&2 <<EOF
❌ Not logged in to LinkedIn.
First-time login (one-shot):
cd "$PROJECT_ROOT"
source .venv/bin/activate
$PYTHON "$ADAPTER_DIR/review.py" login
A Chromium window will pop up — log in to LinkedIn.
Cookie (li_at) will be saved to .auth-linkedin/ for future runs.
EOF
exit 1
fi
# Make sure video_folder exists
mkdir -p "$VIDEO_FOLDER"
# Resolve script path (optional)
SCRIPT_ARG=""
if [[ -n "$SCRIPT_PATH" && -f "$SCRIPT_PATH" ]]; then
SCRIPT_ARG="$SCRIPT_PATH"
fi
# Run from PROJECT_ROOT so .auth-linkedin/ is found; override videos dir to user's
cd "$PROJECT_ROOT"
export CHEAT_PROJECT_ROOT="$PROJECT_ROOT"
export CHEAT_VIDEOS_DIR="$( dirname "$VIDEO_FOLDER" )" # = user's videos/
echo "[linkedin-session] fetching activity=$ACTIVITY_ID into $VIDEO_FOLDER"
if [[ -n "$SCRIPT_ARG" ]]; then
"$PYTHON" "$ADAPTER_DIR/review.py" video "$ACTIVITY_ID" "$SCRIPT_ARG"
else
"$PYTHON" "$ADAPTER_DIR/review.py" video "$ACTIVITY_ID"
fi
# review.py writes to CHEAT_VIDEOS_DIR/<auto-named-folder>/report.md.
# Move it into our canonical video_folder if names differ.
LATEST_REPORT=$(find "$( dirname "$VIDEO_FOLDER" )" -name "report.md" -newer "$VIDEO_FOLDER" -type f 2>/dev/null | head -1)
if [[ -n "$LATEST_REPORT" && "$( dirname "$LATEST_REPORT" )" != "$VIDEO_FOLDER" ]]; then
cp "$LATEST_REPORT" "$VIDEO_FOLDER/report.md"
AUTO_DIR=$( dirname "$LATEST_REPORT" )
if [[ -f "$AUTO_DIR/script.txt" ]]; then
cp "$AUTO_DIR/script.txt" "$VIDEO_FOLDER/script.txt"
fi
echo "[linkedin-session] moved auto-named output to $VIDEO_FOLDER/"
fi
if [[ ! -f "$VIDEO_FOLDER/report.md" ]]; then
echo "❌ report.md not produced — see review.py output above for details" >&2
exit 3
fi
echo "✅ report.md written to $VIDEO_FOLDER/report.md"
exit 0
@@ -0,0 +1,136 @@
"""extract.py 的单元测试。用合成样本(不提交真实指标)。
跑:python test_extract.py
"""
from extract import _to_int, parse_post_meta, parse_post_summary
# 合成样本:复刻真实 /analytics/post-summary/ 的 inner_text 版式(日文界面),数字是假的。
POST_SAMPLE_JP = """Heqing Huangさんが投稿しました • 4日
post body line one
post body line two with a stray number 123 inside
調査
9,999
インプレッション数
800
リーチしたメンバー
プロフィールアクティビティ
5
この投稿からのプロフィール閲覧ユーザー
2
この投稿で獲得したフォロワー
エンゲージメント
30
ソーシャルエンゲージメント
リアクション
20
コメント
6
再投稿
3
保存数
1
LinkedInでの送信数
0
上位統計データ
"""
# 英文界面(LinkedIn 随机切换)。
POST_SAMPLE_EN = """Heqing Huang posted this • 6d
post body text with stray number 999
Discovery
50,000
Impressions
30,000
Members reached
Profile activity
400
Profile viewers from this post
180
Followers gained from this post
Engagement
300
Social engagements
Reactions
100
Comments
25
Reposts
8
Saves
100
Sends on LinkedIn
67
Top demographics
"""
def test_to_int():
assert _to_int("34,057") == 34057
assert _to_int("152") == 152
assert _to_int("1.2K") == 1200
assert _to_int("3M") == 3_000_000
assert _to_int("n/a") is None
def test_parse_post_summary_jp():
m = parse_post_summary(POST_SAMPLE_JP)["metrics"]
assert m["impressions"] == 9999, m
assert m["reach"] == 800, m
assert m["profile_views_from_post"] == 5, m
assert m["followers_from_post"] == 2, m
assert m["social_engagement"] == 30, m
assert m["reactions"] == 20, m
assert m["comments"] == 6, m
assert m["reposts"] == 3, m
assert m["saves"] == 1, m
assert m["sends"] == 0, m
def test_parse_post_summary_en():
m = parse_post_summary(POST_SAMPLE_EN)["metrics"]
assert m["impressions"] == 50000, m
assert m["reach"] == 30000, m
assert m["profile_views_from_post"] == 400, m
assert m["followers_from_post"] == 180, m
assert m["social_engagement"] == 300, m
assert m["reactions"] == 100, m
assert m["comments"] == 25, m
assert m["reposts"] == 8, m
assert m["saves"] == 100, m
assert m["sends"] == 67, m
def test_parse_post_summary_missing_is_none():
assert parse_post_summary("no metrics here")["metrics"]["impressions"] is None
def test_parse_post_meta_jp():
meta = parse_post_meta(POST_SAMPLE_JP)
assert meta["author"] == "Heqing Huang", meta
assert meta["age"] == "4日", meta
assert "post body line one" in meta["text"], meta
assert "post body line two" in meta["text"], meta
# 正文不应吃进指标小标题 / 指标
assert "調査" not in meta["text"], meta
assert "インプレッション数" not in meta["text"], meta
def test_parse_post_meta_en():
meta = parse_post_meta(POST_SAMPLE_EN)
assert meta["author"] == "Heqing Huang", meta
assert meta["age"] == "6d", meta
assert "post body text" in meta["text"], meta
assert "Discovery" not in meta["text"], meta
def test_parse_post_meta_missing():
meta = parse_post_meta("no byline at all\njust text")
assert meta == {"author": "", "age": "", "text": ""}, meta
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"{fn.__name__}")
print(f"\n{len(fns)} passed")
+152
View File
@@ -0,0 +1,152 @@
# Adapter: xhs-explore(小红书爬取)
`/cheat-retro``state.data_collection=adapter` + `platform=xhs` 时自动调用。
> **来源**:照搬 `douyin-session` adapter 的架构(Playwright 持久化登录态 + 被动拦截 XHR),
> 接口路径与字段参考 NanmiCoder/MediaCrawler 与 ReaJason/xhs。已在真实创作者账号端到端验证(2026-05)。
> 2026-06 融合 [xhs-analytics](https://github.com/SingularGuyLeBorn/xhs-analytics) 的公开页解析能力:
> 无登录抓取正文 / 图片 / 标签 / top 评论兜底,以及批量归档与账号汇总命令。
---
## 这个 adapter 是干嘛的
小红书反爬靠 `x-s`/`x-t`/`x-s-common` 签名 + `xsec_token`——纯 HTTP / requests 拿不到数据。
xhs-explore 用 **Playwright + 持久化 Chromium context** 模拟真实浏览器,**不逆向签名、不伪造请求**,
让页面自己发带签名的请求,我们只被动拦截返回的 JSON;同时用公开页 `__INITIAL_STATE__` 做正文/图片/评论兜底:
- 首次扫码登录创作者中心,cookie 存在**你的内容项目根目录** `.auth-xhs/`
- 之后每次抓取复用 cookie
- 抓三路数据:
1. **创作者中心 galaxy 接口**`/api/galaxy/v2/creator/note/user/posted`)— 你**自己**笔记的运营数据:观看/点赞/收藏/评论/分享,**不需要 xsec_token**,最稳。列表每条自带 `xsec_token`,抓自己的笔记无需手动粘带 token 的链接
2. **前台 web API**`/api/sns/web/v1/feed` + `/api/sns/web/v2/comment/page`)— 拿确认字段的点赞/收藏数 + 评论文本
3. **公开页兜底**`www.xiaohongshu.com/explore/<note_id>``window.__INITIAL_STATE__`)— **无需登录**即可补全正文、图片 URL、标签;当前台 API 拿不到评论时,用公开页里的 top 评论兜底(通常 ~10 条)
输出写到**你的内容项目** `videos/<...>/report.md`
调试产物(URL dump / 截图 / galaxy 原始 JSON)写到 `.cheat-cache/xhs-explore-debug/`
## 字段映射(已校准)
galaxy `note/user/posted` 的真实字段名已用真实账号确认并写死在 `crawler.py``_normalize_note()`
观看 `view_count`、点赞 `likes`、收藏 `collected_count`、评论 `comments_count`、分享 `shared_count`
发布时间 `visible_time`unix 秒)/ `time`(本地时间串)、单篇 `xsec_token`
万一小红书改版导致某项显示 0:打开 `videos/<...>/report.md` 末尾的"galaxy 原始字段"JSON
把新 key 加进 `_normalize_note()` 对应的 `_first(v, ...)` 候选列表即可(多候选兜底仍保留)。
## 安装(一次性)
```bash
# 1. 进你的内容项目根目录
cd ~/Documents/my-channel
# 2. 建虚拟环境
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. 装依赖(Playwright + Chromium + requests
pip install -r "$ADAPTER/requirements.txt"
playwright install chromium
# 4. 首次扫码登录小红书创作者中心
ADAPTER=$(find ~/cheat-on-content -name "xhs-explore" -type d 2>/dev/null | head -1)
python "$ADAPTER/crawler.py" login
# → 弹出 Chromium 窗口,用小红书 App 扫码
# → 登录成功后窗口自动关闭,cookie 存在 当前目录/.auth-xhs/
```
> Windows 提示:adapter 在 `~/cheat-on-content/adapters/perf-data/xhs-explore`(克隆源码处),
> 不在 `~/.claude/skills`install.sh 只复制 15 个 skill,不复制 adapter)。
## 用法
cheat-retro 自动调用。手动测试:
```bash
cd ~/Documents/my-channel
source .venv/bin/activate
# 列最近笔记(验证登录态没失效)
python "$ADAPTER/review.py" list
# 抓特定笔记
python "$ADAPTER/review.py" note <note_id> [script.txt]
# 抓特定笔记并下载图片到 videos/<...>/images/
XHS_DOWNLOAD_IMAGES=1 python "$ADAPTER/review.py" note <note_id>
# 批量归档已发布笔记的正文+图片(公开页解析,无登录)
python "$ADAPTER/review.py" archive my_notes.json data/raw/notes/archive 50
# 账号级汇总(基于创作者中心最近 50 条 + 公开页标签)
python "$ADAPTER/review.py" summarize reports
```
输出在 当前目录/videos/<日期>_<标题>/report.mdarchive 输出在 `<output_root>/<note_id>/`
## 怎么拿到 note_id
小红书笔记 URL
- `https://www.xiaohongshu.com/explore/66f1a2b3c4d5e6f700112233?xsec_token=...``note_id = 66f1a2b3c4d5e6f700112233`
- `https://xhslink.com/xxxxx` → 短链,cheat-publish 会 resolve
cheat-publish 登记发布时把 note_id 存到 prediction headercheat-retro 启动时读这个字段。
## report.md 输出格式
`renderer.py` 生成:
- 笔记元信息(标题、发布时间、链接、IP 归属)
- 数据快照(曝光/浏览、点赞、收藏、评论、分享 + 派生比率:赞曝比 / 藏曝比 / 评曝比 / 分曝比 + 涨粉)
- 正文 + 标签(公开页兜底时才有)
- 图片(本地路径优先,否则 URL
- galaxy 原始字段 JSON(debug / 接口改版时核对字段用)
- 原始稿子(cheat-retro 传入)
- Top 评论(按赞数排序,含文本 + 赞数 + IP)
## 失败模式(按概率从高到低)
| 症状 | 原因 | 处理 |
|---|---|---|
| 曝光显示 0 但 JSON 有数 | galaxy 字段 key 不在候选列表 | 看 report.md 里 galaxy JSON,把真实 key 加进 `_normalize_note` |
| `ensure_login` 超时 | cookie 过期 | 重跑 `python crawler.py login` |
| 笔记列表为空 | 创作者中心改了 galaxy 接口路径 | 看 `.cheat-cache/xhs-explore-debug/creator_urls.txt`,更新 `GALAXY_*_KEYS` |
| 评论抓不到 | xsec_token 缺失 / 评论被关 / 登录过期 | 先看 `frontend_urls.txt`;再用公开页兜底 top 评论;最后 manual 粘评论 |
| 正文/图片/标签缺失 | 公开页也解析失败 | 检查 note_id 与 xsec_token;网页结构改版时更新 `_extract_initial_state` |
| Chromium 崩溃 | 内存不足 | 关其他 Chromium`playwright install chromium --force` |
**关键现实**:小红书接口 path`/feed``/comment/page`、galaxy)相对稳定,但签名和 xsec_token 机制常变。
本 adapter 用被动拦截 + 公开页兜底规避签名风险——最易抖动处是 **galaxy 创作者接口**和**评论回复**
## 稳定性等级
★★ — Playwright + 登录态能扛比纯 HTTP 强得多的反爬,但仍受小红书前端改版影响。
评论路径(依赖 xsec_token)比创作者数据路径脆,必要时降级 manual 或公开页兜底。
## 风险提示
- **冷启动用户慎装**Playwright + Chromium ~500MB
- **TOS 风险**:用自己的 cookie 抓自己后台数据是个人用途;别滥用、别高频
- **不要把 `.auth-xhs/` 提交到 git**:cookie 含会话凭据,泄露 = 他人能登录你的小红书账号
- `.cheat-cache/xhs-explore-debug/` 也不应提交(含调试截图 / 接口 URL / 原始 JSON
## 文件清单
```
adapters/perf-data/xhs-explore/
├── README.md # 本文件
├── requirements.txt # playwright>=1.44, requests>=2.28
├── crawler.py # 抓取核心(galaxy / 前台 API / 公开页兜底 / 图片下载)
├── review.py # CLI 入口(login / list / note / archive / summarize
├── renderer.py # 把抓回的 JSON 渲染成 report.md
├── paths.py # 项目根 / .auth-xhs / debug 路径解析
└── run.sh # cheat-retro 调用的 wrapper
```
## 与其他 adapter 的关系
- `douyin-session` — 抖音,本 adapter 的架构来源
- `youtube-data-api`(待)— YouTube 官方 API,更轻
- `bilibili-stat`(待)— B 站官方 stat 接口
如果你做多平台内容,**只装你实际用的 adapter**。
+725
View File
@@ -0,0 +1,725 @@
"""小红书创作者中心 + 前台评论抓取。
登录一次后,Cookie 持久化在 .auth-xhs/,之后直接复用。
一次抓取共享一个 Chromium 会话,稳定性优于每步一个进程。
设计原则(和 douyin-session 一致):
- 不逆向 x-s / x-t 签名、不伪造请求——用登录态浏览器,让页面自己发带签名的请求,
我们只被动拦截返回的 JSON。
- 创作者**自己的**笔记数据走 galaxy 接口(不需要 xsec_token),是最稳的主路。
- 评论走前台 web API(需要 xsec_token),让页面自己导航触发带 token 的请求;
拿不到就优雅降级(report.md 标 comments_unavailablecheat-retro 回落到 manual 粘评论)。
本文件新增的能力(来自 xhs-analytics 的公开页解析):
- fetch_public_note: 无登录解析 explore 页面 __INITIAL_STATE__,拿正文 / 图片 / 标签。
- fetch_public_comments: 公开页兜底 top 评论。
- download_image: 下载笔记图片到本地。
"""
from __future__ import annotations
import asyncio
import json
import re
import urllib.parse
from pathlib import Path
from typing import Any
import requests
from playwright.async_api import BrowserContext, Page, Response, async_playwright
from paths import auth_dir, debug_dir
CREATOR_HOME = "https://creator.xiaohongshu.com/new/home"
CREATOR_NOTE_MANAGER = "https://creator.xiaohongshu.com/new/note-manager"
# galaxy 接口路径片段——宽松匹配,接口偶有版本号变化
GALAXY_NOTE_LIST_KEYS = (
# 松匹配后缀,兼容 /api/galaxy/creator/... 与 /api/galaxy/v2/creator/...(实测是 v2
"/creator/note/user/posted",
)
GALAXY_NOTE_STATS_KEYS = (
"/api/galaxy/creator/data/note_stats",
"/api/galaxy/creator/data/note_detail",
)
# 前台 web API
FEED_KEY = "/api/sns/web/v1/feed"
COMMENT_KEY = "/api/sns/web/v2/comment/page"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
class Session:
"""单浏览器会话,按顺序跑多步抓取。"""
def __init__(self, ctx: BrowserContext, pw: Any) -> None:
self.ctx = ctx
self.pw = pw
@classmethod
async def open(cls, headless: bool = False) -> "Session":
pw = await async_playwright().start()
auth_path = auth_dir()
auth_path.mkdir(parents=True, exist_ok=True)
common_kwargs = {
"user_data_dir": str(auth_path),
"headless": headless,
"viewport": {"width": 1440, "height": 900},
"args": ["--disable-blink-features=AutomationControlled"],
}
try:
ctx = await pw.chromium.launch_persistent_context(**common_kwargs)
except Exception as exc:
# 部分环境只装了系统 Chrome,没下载 Playwright Chromiumfallback 到 channel=chrome
try:
ctx = await pw.chromium.launch_persistent_context(
**common_kwargs, channel="chrome"
)
except Exception:
raise exc
return cls(ctx, pw)
async def close(self) -> None:
try:
await self.ctx.close()
finally:
await self.pw.stop()
# 创作者中心登录凭证——扫码登 creator.xiaohongshu.com 产生的就是这些(galaxy 主路只需它们)。
# 注意:创作者中心登录 *不* 产生 web_session(那是主站 www 前台 cookie),早期版本只认
# web_session 是个 bug,会导致登录成功却一直检测不到、白等超时。
CREATOR_LOGIN_COOKIES = (
"access-token-creator.xiaohongshu.com",
"galaxy_creator_session_id",
"customer-sso-sid",
)
# 主站前台凭证——feed / 评论 web API 需要;只在登录后访问过 www 才会下发。
WEB_LOGIN_COOKIE = "web_session"
async def _cookie_map(ctx: BrowserContext, host: str) -> dict[str, str]:
try:
return {c["name"]: c.get("value", "") for c in await ctx.cookies(host)}
except Exception:
return {}
async def _creator_logged_in(ctx: BrowserContext) -> bool:
names = await _cookie_map(ctx, "https://creator.xiaohongshu.com")
return any(names.get(n) for n in CREATOR_LOGIN_COOKIES)
async def _has_web_session(ctx: BrowserContext) -> bool:
for host in ("https://www.xiaohongshu.com", "https://creator.xiaohongshu.com"):
if (await _cookie_map(ctx, host)).get(WEB_LOGIN_COOKIE):
return True
return False
async def _acquire_web_session(page: Page) -> None:
"""创作者中心登录后,访问主站让 SSO 下发 web_session(前台 feed/评论需要)。best-effort。"""
try:
await page.goto("https://www.xiaohongshu.com/explore",
wait_until="domcontentloaded", timeout=30000)
await asyncio.sleep(3)
if await _has_web_session(page.context):
print("[登录] ✓ 已获取主站 web_session(前台评论/互动可用)")
else:
print("[登录] 注意:未拿到 web_session,前台评论可能要 manualgalaxy 主路不受影响。")
except Exception:
pass
async def ensure_login(timeout_s: int = 180, max_refresh: int = 5) -> bool:
"""扫码登录创作者中心;检测到创作者登录态后顺便换取 web_session,然后自动关闭。
二维码本身会过期,所以用 timeout_s 作为单次等待上限,超时后自动刷新页面重新出码,
最多刷新 max_refresh 次。用户有充足时间扫码。
"""
sess = await Session.open()
try:
page = await sess.ctx.new_page()
await page.goto(CREATOR_HOME)
print(f"[登录] 在弹出的 Chromium 窗口里扫码登录小红书创作者中心。每次二维码有效期约 {timeout_s} 秒,超时自动刷新。")
for refresh in range(max_refresh + 1):
for i in range(timeout_s):
try:
if await _creator_logged_in(sess.ctx) and "login" not in page.url:
print(f"[登录] ✓ 创作者中心登录态已确认(总用时 {refresh * timeout_s + i}s")
await _acquire_web_session(page)
await asyncio.sleep(1)
return True
except Exception:
pass
# 每 30 秒提醒一次,避免用户以为卡死
if i > 0 and i % 30 == 0:
print(f"[登录] 已等待 {i} 秒,请用小红书 App 扫码(或等待自动刷新二维码)……")
await asyncio.sleep(1)
if refresh < max_refresh:
print(f"[登录] 本次二维码未扫码或已过期,正在刷新页面重新出码(第 {refresh + 1}/{max_refresh} 次刷新)……")
try:
await page.reload(wait_until="domcontentloaded", timeout=30000)
except Exception:
await page.goto(CREATOR_HOME)
print("[登录] 超过最大刷新次数仍未检测到登录态,已停止。如需继续请重新运行本命令。")
return False
finally:
await sess.close()
async def fetch_recent_notes(sess: Session, limit: int = 50) -> list[dict]:
"""创作者中心笔记管理页 → 拦截 galaxy 笔记列表 + 单篇运营数据(含曝光/浏览)。"""
captured: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
if any(k in resp.url for k in GALAXY_NOTE_LIST_KEYS + GALAXY_NOTE_STATS_KEYS):
try:
data = await resp.json()
captured.append({"url": resp.url, "data": data})
if len(captured) == 1 and isinstance(data, dict):
print(f"[诊断] galaxy 接口 keys: {list(data.keys())[:8]}")
except Exception:
pass
page.on("response", on_response)
try:
await page.goto(CREATOR_NOTE_MANAGER, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(8)
# cookie 过期时会被 302 到登录页
if "login" in page.url or "redirectReason=401" in page.url:
print("[登录] 创作者中心已跳转登录页,cookie 可能已过期。请运行:python crawler.py login")
return []
for _ in range(4):
await page.evaluate("window.scrollBy(0, 1200)")
await asyncio.sleep(1.5)
notes = _parse_note_list(captured, limit)
if not notes:
_dump(all_urls, "creator_urls.txt", captured, "creator_captured.json")
print(f"[诊断] 笔记列表为空,{len(all_urls)} 个请求已 dump 到 .cheat-cache/xhs-explore-debug/。")
return notes
finally:
await page.close()
def _iter_candidates(data: Any) -> list:
"""从任意 galaxy response 里挖出"笔记数组"。结构多变,宽松找。"""
out: list = []
if isinstance(data, dict):
# 常见包装:{data: {...}} / {data: [...]} / 顶层直接 list 字段
inner = data.get("data") if isinstance(data.get("data"), (dict, list)) else data
targets = [inner] if not isinstance(inner, list) else []
if isinstance(inner, list):
out.extend(inner)
for t in targets:
if isinstance(t, dict):
for key in ("notes", "note_list", "list", "items", "note_stats", "result"):
val = t.get(key)
if isinstance(val, list):
out.extend(val)
return out
def _parse_note_list(captured: list[dict], limit: int) -> list[dict]:
by_id: dict[str, dict] = {}
for item in captured:
for raw in _iter_candidates(item["data"]):
if not isinstance(raw, dict):
continue
note = _normalize_note(raw)
if not note["note_id"]:
continue
# 同一 note 可能在 list 接口和 stats 接口各出现一次——合并,非空字段优先
existing = by_id.get(note["note_id"])
if existing:
for k, v in note.items():
if v and not existing.get(k):
existing[k] = v
else:
by_id[note["note_id"]] = note
return list(by_id.values())[:limit]
def _first(d: dict, *keys: str) -> Any:
for k in keys:
if k in d and d[k] is not None and d[k] != "":
return d[k]
for k in keys:
if k in d and d[k] is not None:
return d[k]
return 0
def _normalize_note(v: dict) -> dict:
note_id = v.get("note_id") or v.get("id") or v.get("noteId") or ""
# 字段名已用真实返回校准(2026-05 /api/galaxy/v2/creator/note/user/posted):
# 观看 view_count | 点赞 likes | 收藏 collected_count | 评论 comments_count
# 分享 shared_count | 发布时间 visible_time(unix秒) | 单篇 token xsec_token
# 确认名放首位,旧候选留作兜底以防接口再次改版。
return {
"note_id": str(note_id),
"title": v.get("display_title") or v.get("title") or v.get("desc") or v.get("name") or "",
"create_time": _to_int(_first(v, "visible_time", "create_time", "post_time", "publish_time")),
"view_count": _to_int(_first(v, "view_count", "view", "imp", "impression", "read_count", "pv")),
"like_count": _to_int(_first(v, "likes", "like_count", "liked_count", "like")),
"collect_count": _to_int(_first(v, "collected_count", "collect_count", "collect", "fav_count")),
"comment_count": _to_int(_first(v, "comments_count", "comment_count", "comment", "cmt_count")),
"share_count": _to_int(_first(v, "shared_count", "share_count", "share")),
"fans_inc": _to_int(_first(v, "fans", "fans_inc", "new_fans", "follow_count")),
"post_time_str": v.get("time") or "", # galaxy 自带本地时间串,比 epoch 省去时区换算
"xsec_token": v.get("xsec_token") or "",
"note_type": v.get("type") or "",
"raw": v,
}
def _to_int(x: Any) -> int:
try:
if isinstance(x, str):
x = x.replace(",", "").strip()
return int(float(x))
except (ValueError, TypeError):
return 0
# ---------------------------------------------------------------------------
# 公开页解析(来自 xhs-analytics 的核心能力):无登录拿正文、图片、标签、评论兜底
# ---------------------------------------------------------------------------
def _extract_initial_state(html: str) -> dict | None:
"""从小红书 explore 页面 HTML 中解析 window.__INITIAL_STATE__。"""
marker = "window.__INITIAL_STATE__="
start = html.find(marker)
if start == -1:
return None
script_start = html.rfind("<script", 0, start)
script_end = html.find("</script>", start)
if script_start == -1 or script_end == -1:
return None
script = html[script_start:script_end]
assign_start = script.find(marker) + len(marker)
json_str = script[assign_start:]
json_str = re.sub(r":\s*undefined\s*([,}\]])", r":null\1", json_str)
json_str = json_str.rstrip().rstrip(';').rstrip()
try:
return json.loads(json_str)
except Exception:
return None
def _image_url(img: dict) -> str:
"""从 imageList 元素中提取可用的图片 URL。"""
if not isinstance(img, dict):
return ""
for key in ("urlDefault", "url"):
if img.get(key):
return img[key]
for info in img.get("infoList", []) or []:
if isinstance(info, dict) and info.get("imageScene") == "WB_DFT" and info.get("url"):
return info["url"]
for info in img.get("infoList", []) or []:
if isinstance(info, dict) and info.get("url"):
return info["url"]
return ""
def _xsec_token_from_url(url: str | None) -> str:
"""从笔记 URL 的 query 中拆出 xsec_token(保留原编码)。"""
if not url:
return ""
parsed = urllib.parse.urlparse(url)
return urllib.parse.unquote(urllib.parse.parse_qs(parsed.query).get("xsec_token", [""])[0])
def fetch_public_note(note_id: str, xsec_token: str) -> dict:
"""无登录抓取 explore 公开页,解析 __INITIAL_STATE__。
返回 dictsuccess, note_id, title, desc/body, images, tags, time, counts, raw。
"""
token = urllib.parse.unquote(xsec_token)
url = (
f"https://www.xiaohongshu.com/explore/{note_id}"
f"?xsec_token={urllib.parse.quote(token)}"
f"&xsec_source=pc_creatormng"
)
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
r = requests.get(url, headers=headers, timeout=20)
r.raise_for_status()
state = _extract_initial_state(r.text)
if not state:
raise ValueError("无法解析页面初始状态")
note_detail_map = state.get("note", {}).get("noteDetailMap", {})
if note_id not in note_detail_map:
raise ValueError("页面未返回该笔记数据")
raw = note_detail_map[note_id]
note = raw.get("note", {})
interact = note.get("interactInfo", {})
return {
"success": True,
"note_id": note_id,
"title": note.get("title", ""),
"desc": note.get("desc", ""),
"body": note.get("desc", ""),
"type": note.get("type", ""),
"time": note.get("time", 0),
"images": [_image_url(img) for img in note.get("imageList", [])],
"tags": [tag.get("name", "") for tag in note.get("tagList", []) if tag.get("name")],
"counts": {
"liked": _to_int(interact.get("likedCount")),
"collected": _to_int(interact.get("collectedCount")),
"comment": _to_int(interact.get("commentCount")),
"shared": _to_int(interact.get("shareCount")),
},
"raw": raw,
}
def _normalize_public_comment(c: dict) -> dict:
"""把 __INITIAL_STATE__ / edith 接口里的评论字段统一成 adapter 格式。"""
user = c.get("user_info") or c.get("userInfo") or {}
like_count = c.get("like_count") or c.get("likeCount") or 0
sub_count = c.get("sub_comment_count") or c.get("subCommentCount") or 0
return {
"cid": str(c.get("id") or c.get("comment_id") or ""),
"text": c.get("content") or "",
"like_count": _to_int(like_count),
"sub_comment_count": _to_int(sub_count),
"create_time": c.get("create_time") or c.get("createTime") or 0,
"user_name": user.get("nickname") or "",
"ip_label": c.get("ip_location") or c.get("ipLocation") or "",
}
def fetch_public_comments(note_id: str, xsec_token: str, max_comments: int = 20) -> list[dict]:
"""公开页 __INITIAL_STATE__ 兜底 top 评论(通常 ~10 条)。"""
token = urllib.parse.unquote(xsec_token)
url = (
f"https://www.xiaohongshu.com/explore/{note_id}"
f"?xsec_token={urllib.parse.quote(token)}"
f"&xsec_source=pc_creatormng"
)
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
r = requests.get(url, headers=headers, timeout=20)
r.raise_for_status()
state = _extract_initial_state(r.text)
if not state:
return []
raw = state.get("note", {}).get("noteDetailMap", {}).get(note_id, {})
comments = []
# __INITIAL_STATE__ 里的评论路径可能是 comments.list 或 commentsList
for key in ("comments", "commentsList"):
container = raw.get(key) if isinstance(raw, dict) else None
if isinstance(container, dict):
arr = container.get("list") or container.get("comments") or []
if isinstance(arr, list):
comments.extend([_normalize_public_comment(c) for c in arr])
elif isinstance(container, list):
comments.extend([_normalize_public_comment(c) for c in container])
seen = set()
dedup = []
for c in comments:
if not c["cid"] or c["cid"] in seen:
continue
seen.add(c["cid"])
dedup.append(c)
dedup.sort(key=lambda x: x["like_count"], reverse=True)
return dedup[:max_comments]
async def download_image(url: str, dest: Path, timeout: int = 30) -> bool:
"""下载单张图片到 dest(会根据 Content-Type 修正扩展名)。异步封装。"""
if not url:
return False
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
def _download() -> bool:
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://www.xiaohongshu.com/",
}
r = requests.get(url, headers=headers, timeout=timeout, stream=True)
r.raise_for_status()
parsed = urllib.parse.urlparse(url)
path = urllib.parse.unquote(parsed.path)
ext = Path(path).suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"}:
ct = r.headers.get("Content-Type", "").lower()
if "webp" in ct:
ext = ".webp"
elif "png" in ct:
ext = ".png"
elif "gif" in ct:
ext = ".gif"
else:
ext = ".jpg"
dest_final = dest.with_suffix(ext)
with open(dest_final, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
return True
try:
return await asyncio.to_thread(_download)
except Exception as exc:
print(f"[下载图片] 失败 {url}: {exc}")
return False
async def fetch_note_frontend(
sess: Session,
note_id: str,
note_url: str | None = None,
xsec_token: str | None = None,
) -> dict:
"""打开前台笔记页 → 拦截 feedinteract_info 确认字段)+ comment/page。
前台需要 xsec_token + 登录态(web_session)。
- 若传入 note_url(含 xsec_token,如从创作者后台复制的 ?xsec_token=...&xsec_source=pc_creatormng
→ 直接用它导航,最稳。
- 否则退回裸 explore URL(仅对已登录账号访问自己笔记可能可行)。
token 缺失 / 未登录 → dump 并降级(评论留给 manual)。
拦截不到评论时,会用公开页 __INITIAL_STATE__ 里的 top 评论兜底。
"""
feed: dict = {}
comments: list[dict] = []
all_urls: list[str] = []
page = await sess.ctx.new_page()
async def on_response(resp: Response) -> None:
all_urls.append(resp.url)
if FEED_KEY in resp.url:
try:
data = await resp.json()
ii = _extract_interact(data)
if ii:
feed.update(ii)
except Exception:
pass
elif COMMENT_KEY in resp.url:
try:
data = await resp.json()
for c in _extract_comments(data):
comments.append(c)
except Exception:
pass
page.on("response", on_response)
try:
url = note_url or f"https://www.xiaohongshu.com/explore/{note_id}"
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
except Exception as e:
print(f"[警告] 笔记页加载异常:{e}")
await asyncio.sleep(5)
if "website-login/error" in page.url or "登录" in (await page.title()):
print("[警告] 触发登录墙(安全限制)——cookie 未登录或已过期。先跑 crawler.py login 扫码。")
# 滚动评论区触发分页懒加载
last = 0
stagnant = 0
for _ in range(40):
await page.evaluate("window.scrollBy(0, 1400)")
await asyncio.sleep(1.8)
cur = len({c["cid"] for c in comments})
if cur == last:
stagnant += 1
if stagnant >= 5:
break
else:
stagnant = 0
last = cur
# 兜底:公开页 __INITIAL_STATE__ 里的 top 评论
if not comments:
xsec = xsec_token or _xsec_token_from_url(note_url)
if xsec:
try:
public_comments = await asyncio.to_thread(
fetch_public_comments, note_id, xsec, max_comments=20
)
if public_comments:
comments = public_comments
print(f" 公开页兜底 {len(comments)} 条评论")
except Exception as exc:
print(f"[诊断] 公开页评论兜底失败:{exc}")
if not comments:
dbg = debug_dir()
dbg.mkdir(parents=True, exist_ok=True)
try:
await page.screenshot(path=str(dbg / f"note_{note_id}.png"))
except Exception:
pass
(dbg / "frontend_urls.txt").write_text("\n".join(all_urls), encoding="utf-8")
print("[诊断] 前台未拦到评论(可能 xsec_token 缺失或评论被关),已 dump URL。")
# 去重 + 按赞降序
seen = set()
dedup = []
for c in comments:
if c["cid"] in seen:
continue
seen.add(c["cid"])
dedup.append(c)
dedup.sort(key=lambda x: x["like_count"], reverse=True)
print(f" 前台共 {len(dedup)} 条评论")
return {"interact": feed, "comments": dedup}
finally:
await page.close()
def _extract_interact(data: Any) -> dict:
"""从 feed response 里挖 interact_info(确认字段)。"""
if not isinstance(data, dict):
return {}
items = []
d = data.get("data", data)
if isinstance(d, dict):
items = d.get("items") or d.get("note_list") or []
for it in items if isinstance(items, list) else []:
node = it.get("note_card") or it.get("note") or it
ii = node.get("interact_info") if isinstance(node, dict) else None
if isinstance(ii, dict):
return {
"like_count": _to_int(ii.get("liked_count")),
"collect_count": _to_int(ii.get("collected_count")),
"comment_count": _to_int(ii.get("comment_count")),
"share_count": _to_int(ii.get("share_count")),
"ip_location": node.get("ip_location") or "",
}
return {}
def _extract_comments(data: Any) -> list[dict]:
"""comment/page response → 评论列表(确认字段)。"""
out: list[dict] = []
if not isinstance(data, dict):
return out
d = data.get("data", data)
arr = d.get("comments") if isinstance(d, dict) else None
for c in arr or []:
if not isinstance(c, dict):
continue
user = c.get("user_info") or {}
out.append({
"cid": str(c.get("id") or c.get("comment_id") or ""),
"text": c.get("content") or "",
"like_count": _to_int(c.get("like_count")),
"sub_comment_count": _to_int(c.get("sub_comment_count")),
"create_time": c.get("create_time") or 0,
"user_name": user.get("nickname") or "",
"ip_label": c.get("ip_location") or "",
})
return out
def _dump(urls: list[str], url_file: str, captured: list[dict], cap_file: str) -> None:
dbg = debug_dir()
dbg.mkdir(parents=True, exist_ok=True)
(dbg / url_file).write_text("\n".join(urls), encoding="utf-8")
try:
(dbg / cap_file).write_text(
json.dumps([c["data"] for c in captured][:5], ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
def _merge_public_note(note: dict, public: dict) -> None:
"""用公开页数据补全 note(不覆盖已有的 galaxy 运营数据)。"""
if not public.get("success"):
return
for key in ("title", "desc", "body", "type", "images", "tags"):
if public.get(key) and not note.get(key):
note[key] = public[key]
if public.get("time") and not note.get("create_time"):
note["create_time"] = _to_int(public["time"])
counts = public.get("counts") or {}
mapping = {
"like_count": counts.get("liked"),
"collect_count": counts.get("collected"),
"comment_count": counts.get("comment"),
"share_count": counts.get("shared"),
}
for k, v in mapping.items():
if v and not note.get(k):
note[k] = _to_int(v)
async def fetch_all(note_id: str, note_url: str | None = None) -> dict:
"""一个会话跑完笔记列表(含 galaxy 指标)+ 前台 interact + 评论 + 公开页正文/图片兜底。"""
sess = await Session.open()
try:
print(" → 打开创作者中心,拉笔记列表 + 运营数据")
notes = await fetch_recent_notes(sess, limit=50)
note = next((n for n in notes if n["note_id"] == note_id), None)
if not note:
print(f" 未在最近 {len(notes)} 条里找到 {note_id},用最小元数据继续。")
note = _normalize_note({"note_id": note_id})
else:
print(f"{(note.get('title') or '')[:40]}(曝光 {note.get('view_count')}")
# 抓自己的笔记时,galaxy 列表已带每条的 xsec_token——自动拼前台 URL,免得手动粘 token 链接
front_url = note_url
if not front_url and note.get("xsec_token"):
front_url = (f"https://www.xiaohongshu.com/explore/{note_id}"
f"?xsec_token={note['xsec_token']}&xsec_source=pc_creatormng")
xsec = _xsec_token_from_url(front_url) or note.get("xsec_token", "")
# 先用公开页补正文/图片/标签(无登录、低成本),不覆盖 galaxy 已确认的计数
if xsec:
try:
public = await asyncio.to_thread(fetch_public_note, note_id, xsec)
_merge_public_note(note, public)
print(" ✓ 公开页正文/图片/标签已补全")
except Exception as exc:
print(f"[诊断] 公开页兜底失败:{exc}")
print(" → 打开前台笔记页抓 interact + 评论")
front = await fetch_note_frontend(sess, note_id, note_url=front_url, xsec_token=xsec)
# 前台 interact 字段是确认的——用它补全/覆盖 galaxy 里可能缺的计数
for k in ("like_count", "collect_count", "comment_count", "share_count"):
if front["interact"].get(k):
note[k] = front["interact"][k]
if front["interact"].get("ip_location"):
note["ip_location"] = front["interact"]["ip_location"]
return {"note": note, "comments": front["comments"]}
finally:
await sess.close()
if __name__ == "__main__":
asyncio.run(ensure_login())
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
def runtime_project_root(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_PROJECT_ROOT"):
return Path(active_env["CHEAT_PROJECT_ROOT"]).expanduser().resolve()
base_cwd = cwd if cwd is not None else Path.cwd()
return Path(base_cwd).expanduser().resolve()
def auth_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
return runtime_project_root(env=env, cwd=cwd) / ".auth-xhs"
def debug_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
return runtime_project_root(env=env, cwd=cwd) / ".cheat-cache" / "xhs-explore-debug"
def videos_dir(
env: Mapping[str, str] | None = None,
cwd: Path | None = None,
) -> Path:
active_env = env if env is not None else os.environ
if active_env.get("CHEAT_VIDEOS_DIR"):
return Path(active_env["CHEAT_VIDEOS_DIR"]).expanduser().resolve()
return runtime_project_root(env=env, cwd=cwd) / "videos"
+142
View File
@@ -0,0 +1,142 @@
"""把抓到的小红书数据渲染成 report.mdcheat-retro 读这个文件)。"""
from __future__ import annotations
import datetime as dt
import json
from pathlib import Path
def _fmt_time(ts) -> str:
if not ts:
return "未知"
try:
ts = int(ts)
# 小红书部分接口用毫秒时间戳
if ts > 1e12:
ts //= 1000
return dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
except (ValueError, TypeError, OSError):
return str(ts)
def _fmt_num(n) -> str:
if n is None:
return "-"
try:
n = int(n)
except (ValueError, TypeError):
return str(n)
if n >= 10000:
return f"{n/10000:.1f}w"
return str(n)
def _ratio(num, denom) -> str:
try:
num, denom = int(num), int(denom)
except (ValueError, TypeError):
return "-"
if denom <= 0:
return "-"
return f"{num/denom*100:.2f}%"
def _escape_md(text: str) -> str:
"""轻度转义,避免正文里的 # 被误认为标题。"""
return text.replace("\n", " \n").replace("#", "\\#")
def render_report(note: dict, script: str, comments: list[dict]) -> str:
lines: list[str] = []
title = note.get("title") or "(无标题)"
note_id = note["note_id"]
view = note.get("view_count") or 0
lines.append(f"# {title}")
lines.append("")
lines.append(f"- 笔记 ID`{note_id}`")
lines.append(f"- 发布时间:{note.get('post_time_str') or _fmt_time(note.get('create_time', 0))}")
lines.append(f"- 链接:https://www.xiaohongshu.com/explore/{note_id}")
lines.append(f"- 抓取时间:{dt.datetime.now().strftime('%Y-%m-%d %H:%M')}")
if note.get("ip_location"):
lines.append(f"- IP 归属:{note['ip_location']}")
lines.append("")
lines.append("## 数据快照")
lines.append("")
lines.append(f"- 曝光/浏览:{_fmt_num(view)}")
lines.append(f"- 点赞:{_fmt_num(note.get('like_count'))}(赞曝比 {_ratio(note.get('like_count'), view)}")
lines.append(f"- 收藏:{_fmt_num(note.get('collect_count'))}(藏曝比 {_ratio(note.get('collect_count'), view)}")
lines.append(f"- 评论:{_fmt_num(note.get('comment_count'))}(评曝比 {_ratio(note.get('comment_count'), view)}")
lines.append(f"- 分享:{_fmt_num(note.get('share_count'))}(分曝比 {_ratio(note.get('share_count'), view)}")
if note.get("fans_inc"):
lines.append(f"- 涨粉:{_fmt_num(note.get('fans_inc'))}")
lines.append("")
# 正文 + 标签(公开页兜底时才有)
body = note.get("body") or note.get("desc") or ""
tags = note.get("tags") or []
if body or tags:
lines.append("## 正文")
lines.append("")
if tags:
lines.append(" ".join(f"#{t}" for t in tags))
lines.append("")
lines.append(_escape_md(body) if body else "(无)")
lines.append("")
# 图片(本地优先,否则用 URL
image_paths = note.get("image_paths") or []
image_urls = note.get("images") or []
images = image_paths or image_urls
if images:
lines.append("## 图片")
lines.append("")
for i, img in enumerate(images, 1):
lines.append(f"![图片{i}]({img})")
lines.append("")
# galaxy 原始 JSON——首跑时用来确认曝光字段的真实 key 名
raw = note.get("raw")
if raw:
lines.append("### galaxy 原始字段(首跑校准用,确认曝光字段 key 后可忽略)")
lines.append("")
lines.append("```json")
full = json.dumps(raw, ensure_ascii=False, indent=2)
truncated = full[:2500]
if len(full) > 2500:
truncated += "\n... (truncated)"
lines.append(truncated)
lines.append("```")
lines.append("")
lines.append("## 原始稿子")
lines.append("")
lines.append(script.strip() if script.strip() else "(未提供)")
lines.append("")
lines.append(f"## 评论(按点赞降序,共 {len(comments)} 条)")
lines.append("")
if not comments:
lines.append("(未抓到评论——可能 xsec_token 缺失、评论被关或需手动粘贴)")
else:
for c in comments:
reply = f" 💬{c['sub_comment_count']}" if c.get("sub_comment_count") else ""
ip = f" [{c['ip_label']}]" if c.get("ip_label") else ""
text = (c.get("text") or "").replace("\n", " ").strip()
lines.append(f"- [👍{c['like_count']}{reply}]{ip} {text}")
lines.append("")
return "\n".join(lines)
def slugify(text: str, max_len: int = 30) -> str:
bad = '<>:"/\\|?*\n\r\t'
out = "".join("_" if ch in bad else ch for ch in text).strip()
return out[:max_len] or "untitled"
def output_dir_for(note: dict, root: Path) -> Path:
date = (note.get("post_time_str") or _fmt_time(note.get("create_time", 0)))[:10].replace("未知", "nodate")
slug = slugify(note.get("title") or note["note_id"])
return root / f"{date}_{slug}"
@@ -0,0 +1,2 @@
playwright>=1.44
requests>=2.28
+412
View File
@@ -0,0 +1,412 @@
"""发完笔记后跑一次:抓数据 + 评论 → 生成 report.md。
用法:
python review.py # 交互式选笔记
python review.py login # 仅登录(首次扫码)
python review.py list # 列最近笔记(验证登录态)
python review.py note <note_id> [script.txt] # 直接指定笔记
python review.py archive <notes.json> [output_root] [limit] # 批量归档正文+图片
python review.py summarize [out_dir] # 账号级汇总(基于最近 50 条)
archive / summarize 能力来自 xhs-analytics 的公开页解析,可无登录抓取正文与图片。
"""
from __future__ import annotations
import asyncio
import io
import json
import os
import re
import sys
import time
from collections import defaultdict
from pathlib import Path
import crawler
import renderer
from paths import runtime_project_root, videos_dir
LOG_FILE: io.TextIOWrapper | None = None
def _log(msg: str) -> None:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
try:
print(line)
except Exception:
pass
if LOG_FILE is not None:
try:
LOG_FILE.write(line + "\n")
LOG_FILE.flush()
except Exception:
pass
def _parse_note_arg(arg: str) -> tuple[str, str | None]:
"""接受 note_id 或完整笔记 URL(含 xsec_token)。返回 (note_id, note_url|None)。"""
arg = arg.strip().strip("'").strip('"')
if arg.startswith("http"):
m = re.search(r"/(?:explore|discovery/item)/([0-9a-zA-Z]+)", arg)
note_id = m.group(1) if m else arg
return note_id, arg
return arg, None
def _prompt(msg: str) -> str:
try:
return input(msg).strip()
except EOFError:
return ""
def _pick_note(notes: list[dict]) -> dict | None:
if not notes:
print("未抓到笔记列表。请确认创作者中心已登录,或页面结构已变,需要更新 crawler。")
return None
print("\n最近笔记:")
for i, n in enumerate(notes):
t = renderer._fmt_time(n.get("create_time", 0))
title = (n.get("title") or "").replace("\n", " ")[:40]
print(f" [{i}] {t} | 曝光 {renderer._fmt_num(n.get('view_count'))} | {title}")
choice = _prompt("\n选择序号(回车取消):")
if not choice.isdigit():
return None
idx = int(choice)
if 0 <= idx < len(notes):
return notes[idx]
return None
async def run() -> None:
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
print("[选笔记] 打开创作者中心拉列表……")
sess = await crawler.Session.open()
try:
notes = await crawler.fetch_recent_notes(sess, limit=10)
finally:
await sess.close()
note = _pick_note(notes)
if not note:
print("已取消。")
return
script_raw = _prompt("把稿子 txt 拖进来(或回车跳过):")
script_path: str | None = None
if script_raw.strip():
p = Path(script_raw.strip().strip("'").strip('"').replace("\\ ", " ")).expanduser()
if p.is_file():
script_path = str(p)
else:
print(f"[警告] 找不到 {p},稿子留空。")
await run_with_id(note["note_id"], script_path)
async def run_with_id(note_arg: str, script_path: str | None) -> None:
active_videos_dir = videos_dir()
active_videos_dir.mkdir(parents=True, exist_ok=True)
note_id, note_url = _parse_note_arg(note_arg)
script = ""
if script_path:
p = Path(script_path).expanduser()
if p.is_file():
script = p.read_text(encoding="utf-8", errors="ignore")
print(f"稿子:{p.name}{len(script)} 字符)")
else:
print(f"[警告] 找不到稿子 {p}")
print(f"[抓取] 笔记 {note_id}" + ("(带 token URL" if note_url else ""))
result = await crawler.fetch_all(note_id, note_url=note_url)
note = result["note"]
comments = result["comments"]
out_dir = renderer.output_dir_for(note, active_videos_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if script:
(out_dir / "script.txt").write_text(script, encoding="utf-8")
# 可选:下载图片到本地(默认关闭;通过环境变量 XHS_DOWNLOAD_IMAGES=1 开启)
if os.environ.get("XHS_DOWNLOAD_IMAGES", "").lower() in ("1", "true", "yes"):
img_dir = out_dir / "images"
img_dir.mkdir(parents=True, exist_ok=True)
paths: list[str] = []
for i, url in enumerate(note.get("images", []), 1):
dest = img_dir / f"{i:02d}"
if await crawler.download_image(url, dest):
# download_image 会根据 Content-Type 修正扩展名,这里找回真实文件名
actual = next(dest.parent.glob(f"{dest.name}.*"), dest)
paths.append(str(actual.relative_to(out_dir)).replace("\\", "/"))
if paths:
note["image_paths"] = paths
md = renderer.render_report(note, script, comments)
report = out_dir / "report.md"
report.write_text(md, encoding="utf-8")
print(f"\n{report}")
# ---------------------------------------------------------------------------
# archive: 批量归档已发布笔记的正文、图片、标签(公开页解析,无登录)
# ---------------------------------------------------------------------------
async def run_archive(input_path: Path, output_root: Path, limit: int | None = None,
max_comments: int = 0) -> None:
global LOG_FILE
output_root.mkdir(parents=True, exist_ok=True)
log_path = output_root / f"archive_{time.strftime('%Y%m%d_%H%M%S')}.log"
LOG_FILE = open(log_path, "w", encoding="utf-8", errors="replace")
try:
_log(f"启动归档: input={input_path}, output={output_root}")
notes = json.loads(input_path.read_text(encoding="utf-8"))
if not isinstance(notes, list):
raise ValueError("输入 JSON 必须是笔记列表")
notes = sorted(notes, key=lambda x: x.get("time", ""), reverse=True)
if limit:
notes = notes[:limit]
results: list[dict] = []
for i, note in enumerate(notes, 1):
note_id = note.get("id") or note.get("note_id")
xsec = note.get("xsecToken") or note.get("xsec_token", "")
title = note.get("title", "")
if not note_id or not xsec:
results.append({
"success": False,
"note_id": note_id,
"error": "缺少 note_id 或 xsec_token",
})
continue
out_dir = output_root / note_id
_log(f"({i}/{len(notes)}) 归档 {note_id} {title[:30]}...")
try:
public = await asyncio.to_thread(crawler.fetch_public_note, note_id, xsec)
if not public.get("success"):
err = public.get("error", "公开页解析失败")
_log(f" -> 失败: {err}")
results.append({"success": False, "note_id": note_id, "error": err})
continue
if max_comments > 0:
comments = await asyncio.to_thread(
crawler.fetch_public_comments, note_id, xsec, max_comments
)
public["comments"] = comments
public["comments_fetched"] = len(comments)
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "note_detail.json").write_text(
json.dumps(public, ensure_ascii=False, indent=2), encoding="utf-8"
)
img_dir = out_dir / "images"
img_dir.mkdir(parents=True, exist_ok=True)
img_count = 0
for idx, url in enumerate(public.get("images", []), 1):
if await crawler.download_image(url, img_dir / f"{idx:02d}"):
img_count += 1
summary = {
"success": True,
"note_id": note_id,
"output_dir": str(out_dir),
"image_count": img_count,
"comment_count": public.get("comments_fetched", 0),
}
_log(f" -> 完成: 图片 {img_count}")
results.append(summary)
except Exception as e:
_log(f" -> 异常: {e}")
results.append({"success": False, "note_id": note_id, "error": str(e)})
if i < len(notes):
await asyncio.sleep(0.5)
summary_path = output_root / f"archive_summary_{time.strftime('%Y%m%d_%H%M%S')}.json"
summary_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
success = sum(1 for r in results if r.get("success"))
_log(f"完成: 成功 {success}, 失败 {len(results) - success}, 总计 {len(results)}")
_log(f"汇总: {summary_path}")
finally:
if LOG_FILE is not None:
LOG_FILE.close()
LOG_FILE = None
# ---------------------------------------------------------------------------
# summarize: 账号级汇总(基于创作者中心最近笔记 + 公开页标签)
# ---------------------------------------------------------------------------
def _series_of(note: dict) -> str:
"""按标签/标题关键字给笔记归系列,简单兜底。"""
tags = note.get("tags") or []
if tags:
return tags[0]
title = note.get("title", "")
# 常见系列关键字(可扩展)
for kw in ("Agent", "RAG", "CS336", "Lec", "MCP", "LLM"):
if kw.lower() in title.lower():
return kw
return "其他"
def _summarize(notes: list[dict]) -> str:
lines: list[str] = []
lines.append("# xhs-explore 账号汇总\n")
lines.append(f"生成时间:{time.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"样本数:{len(notes)}\n")
total_view = sum(n.get("view_count", 0) or 0 for n in notes)
total_like = sum(n.get("like_count", 0) or 0 for n in notes)
total_collect = sum(n.get("collect_count", 0) or 0 for n in notes)
total_comment = sum(n.get("comment_count", 0) or 0 for n in notes)
lines.append("## 整体数据\n")
lines.append(f"- 总浏览:{total_view:,}")
lines.append(f"- 总点赞:{total_like:,}")
lines.append(f"- 总收藏:{total_collect:,}")
lines.append(f"- 总评论:{total_comment:,}")
if total_view:
lines.append(f"- 平均赞阅比:{total_like / total_view * 100:.2f}%")
lines.append(f"- 平均藏阅比:{total_collect / total_view * 100:.2f}%")
lines.append("")
# 按浏览 Top 10
sorted_notes = sorted(notes, key=lambda x: x.get("view_count", 0) or 0, reverse=True)[:10]
lines.append("## 浏览量 Top 10\n")
lines.append("| 排名 | 标题 | 浏览 | 点赞 | 收藏 | 评论 |")
lines.append("|------|------|------|------|------|------|")
for idx, n in enumerate(sorted_notes, 1):
title = (n.get("title") or "")[:30]
lines.append(
f"| {idx} | {title} | {n.get('view_count', 0):,} | "
f"{n.get('like_count', 0)} | {n.get('collect_count', 0)} | {n.get('comment_count', 0)} |"
)
lines.append("")
# 系列汇总
series: dict[str, list[dict]] = defaultdict(list)
for n in notes:
series[_series_of(n)].append(n)
lines.append("## 系列汇总(按首标签/标题关键字)\n")
lines.append("| 系列 | 篇数 | 总浏览 | 均浏览 | 赞阅比 | 藏阅比 | 评阅比 |")
lines.append("|------|------|--------|--------|--------|--------|--------|")
for name, items in sorted(series.items(), key=lambda x: sum(i.get("view_count", 0) or 0 for i in x[1]), reverse=True):
views = sum(i.get("view_count", 0) or 0 for i in items)
likes = sum(i.get("like_count", 0) or 0 for i in items)
collects = sum(i.get("collect_count", 0) or 0 for i in items)
comments = sum(i.get("comment_count", 0) or 0 for i in items)
avg_view = views / len(items) if items else 0
like_rate = likes / views * 100 if views else 0
collect_rate = collects / views * 100 if views else 0
comment_rate = comments / views * 100 if views else 0
lines.append(
f"| {name} | {len(items)} | {views:,} | {avg_view:.0f} | "
f"{like_rate:.2f}% | {collect_rate:.2f}% | {comment_rate:.2f}% |"
)
lines.append("")
lines.append(
"> 提示:系列判定优先取公开页标签,无标签时按标题关键字兜底。"
"可通过 `review.py archive` 批量归档后人工校准。\n"
)
return "\n".join(lines)
async def run_summarize(out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
print("[summarize] 拉取创作者中心最近 50 条笔记……")
# summarize 是批量后台命令,用 headless 避免弹窗打扰
sess = await crawler.Session.open(headless=True)
try:
notes = await crawler.fetch_recent_notes(sess, limit=50)
finally:
await sess.close()
# 用公开页补全标签/正文/图片(失败也不阻塞)
for n in notes:
xsec = n.get("xsec_token") or ""
if not xsec:
continue
try:
public = await asyncio.to_thread(crawler.fetch_public_note, n["note_id"], xsec)
crawler._merge_public_note(n, public)
except Exception:
pass
md = _summarize(notes)
out_path = out_dir / f"xhs_summary_{time.strftime('%Y%m%d_%H%M%S')}.md"
out_path.write_text(md, encoding="utf-8")
print(f"\n{out_path}")
def _usage() -> str:
return """
用法:
python review.py 交互式选笔记
python review.py login 扫码登录
python review.py list 列最近笔记
python review.py note <note_id> [script.txt]
python review.py archive <notes.json> [output_root] [limit]
python review.py summarize [out_dir]
""".strip()
def main() -> None:
# Windows Git Bash 等默认 GBK 控制台,emoji/中文 print 会崩溃,强制 UTF-8 输出
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
if len(sys.argv) > 1 and sys.argv[1] == "login":
asyncio.run(crawler.ensure_login())
return
if len(sys.argv) > 1 and sys.argv[1] == "note":
if len(sys.argv) < 3:
print("用法:python review.py note <note_id> [script.txt]")
sys.exit(2)
note_id = sys.argv[2]
script_path = sys.argv[3] if len(sys.argv) > 3 else None
asyncio.run(run_with_id(note_id, script_path))
return
if len(sys.argv) > 1 and sys.argv[1] == "list":
async def _list() -> None:
sess = await crawler.Session.open()
try:
notes = await crawler.fetch_recent_notes(sess, limit=20)
finally:
await sess.close()
for i, n in enumerate(notes):
t = renderer._fmt_time(n.get("create_time", 0))
title = (n.get("title") or "").replace("\n", " ")[:50]
print(f"[{i}] {n['note_id']} {t} 曝光{renderer._fmt_num(n.get('view_count'))} {title}")
asyncio.run(_list())
return
if len(sys.argv) > 1 and sys.argv[1] == "archive":
if len(sys.argv) < 3:
print(_usage())
sys.exit(2)
input_path = Path(sys.argv[2].strip('"').strip("'")).expanduser().resolve()
output_root = Path(sys.argv[3]).expanduser().resolve() if len(sys.argv) > 3 else runtime_project_root() / "data" / "raw" / "notes" / "archive"
limit = int(sys.argv[4]) if len(sys.argv) > 4 else None
asyncio.run(run_archive(input_path, output_root, limit=limit))
return
if len(sys.argv) > 1 and sys.argv[1] == "summarize":
out_dir = Path(sys.argv[2]).expanduser().resolve() if len(sys.argv) > 2 else runtime_project_root() / "reports"
asyncio.run(run_summarize(out_dir))
return
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
print(_usage())
return
asyncio.run(run())
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
#
# xhs-explore adapter wrapper(小红书)
#
# Called by /cheat-retro when state.data_collection=adapter and platform=xhs.
#
# Usage:
# bash run.sh <note_id> <video_folder> [<script_path>]
#
# Example:
# bash run.sh 66f1a2b3c4d5e6f700112233 ~/my-channel/videos/2026-05-04_abc123_标题
#
# Output: writes report.md INTO the video_folder.
# Exit codes:
# 0 = success (report.md written)
# 1 = login expired or required
# 2 = adapter dependency missing (playwright not installed)
# 3 = other failure (network, parse error, etc.)
set -uo pipefail
NOTE_ID="${1:-}"
VIDEO_FOLDER="${2:-}"
SCRIPT_PATH="${3:-}"
if [[ -z "$NOTE_ID" || -z "$VIDEO_FOLDER" ]]; then
echo "Usage: bash run.sh <note_id> <video_folder> [<script_path>]" >&2
exit 3
fi
ADAPTER_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Find Python — prefer venv in user's project root if exists
PYTHON=""
# Walk up from VIDEO_FOLDER to find project root (.cheat-state.json)
PROJECT_ROOT="$( realpath "$VIDEO_FOLDER" )"
while [[ "$PROJECT_ROOT" != "/" && ! -f "$PROJECT_ROOT/.cheat-state.json" ]]; do
PROJECT_ROOT="$( dirname "$PROJECT_ROOT" )"
done
if [[ ! -f "$PROJECT_ROOT/.cheat-state.json" ]]; then
echo "❌ Cannot find project root (.cheat-state.json) from $VIDEO_FOLDER" >&2
exit 3
fi
if [[ -x "$PROJECT_ROOT/.venv/bin/python" ]]; then
PYTHON="$PROJECT_ROOT/.venv/bin/python"
elif [[ -x "$PROJECT_ROOT/.venv/Scripts/python.exe" ]]; then
PYTHON="$PROJECT_ROOT/.venv/Scripts/python.exe" # Windows venv layout
elif command -v python3 >/dev/null 2>&1; then
PYTHON="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON="python"
else
echo "❌ python not found — install Python 3.10+ first" >&2
exit 2
fi
# Verify playwright is installed
if ! "$PYTHON" -c "import playwright" 2>/dev/null; then
cat >&2 <<EOF
❌ playwright not installed.
Install in your project venv:
cd "$PROJECT_ROOT"
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\\Scripts\\activate
pip install -r "$ADAPTER_DIR/requirements.txt"
playwright install chromium
Then re-run /cheat-retro.
EOF
exit 2
fi
# Verify auth dir exists in project root (cookie persistence)
if [[ ! -d "$PROJECT_ROOT/.auth-xhs" ]]; then
cat >&2 <<EOF
❌ Not logged in to 小红书 创作者中心.
First-time login (one-shot):
cd "$PROJECT_ROOT"
source .venv/bin/activate # Windows: .venv\\Scripts\\activate
$PYTHON "$ADAPTER_DIR/crawler.py" login
A Chromium window will pop up — scan QR with your phone to log in.
Cookie will be saved to .auth-xhs/ for future runs.
EOF
exit 1
fi
mkdir -p "$VIDEO_FOLDER"
SCRIPT_ARG=""
if [[ -n "$SCRIPT_PATH" && -f "$SCRIPT_PATH" ]]; then
SCRIPT_ARG="$SCRIPT_PATH"
fi
# Run from PROJECT_ROOT so .auth-xhs/ is found and outputs go to expected paths
cd "$PROJECT_ROOT"
export CHEAT_PROJECT_ROOT="$PROJECT_ROOT"
export CHEAT_VIDEOS_DIR="$( dirname "$VIDEO_FOLDER" )"
echo "[xhs-explore] fetching note_id=$NOTE_ID into $VIDEO_FOLDER"
if [[ -n "$SCRIPT_ARG" ]]; then
"$PYTHON" "$ADAPTER_DIR/review.py" note "$NOTE_ID" "$SCRIPT_ARG"
else
"$PYTHON" "$ADAPTER_DIR/review.py" note "$NOTE_ID"
fi
# review.py writes to CHEAT_VIDEOS_DIR/<auto-named-folder>/report.md.
# Move the just-written report into our canonical video_folder if names differ.
LATEST_REPORT=$(find "$( dirname "$VIDEO_FOLDER" )" -name "report.md" -newer "$VIDEO_FOLDER" -type f 2>/dev/null | head -1)
if [[ -n "$LATEST_REPORT" && "$( dirname "$LATEST_REPORT" )" != "$VIDEO_FOLDER" ]]; then
cp "$LATEST_REPORT" "$VIDEO_FOLDER/report.md"
AUTO_DIR=$( dirname "$LATEST_REPORT" )
if [[ -f "$AUTO_DIR/script.txt" ]]; then
cp "$AUTO_DIR/script.txt" "$VIDEO_FOLDER/script.txt"
fi
echo "[xhs-explore] moved auto-named output to $VIDEO_FOLDER/"
fi
if [[ ! -f "$VIDEO_FOLDER/report.md" ]]; then
echo "❌ report.md not produced — see review.py output above for details" >&2
exit 3
fi
echo "✅ report.md written to $VIDEO_FOLDER/report.md"
exit 0
@@ -0,0 +1,127 @@
# Adapter: whisper(视频/音频转录)
`/cheat-learn-from` 在 Way b(用户提供视频文件,让工具转录)时调用。
> **优先 Way a**(用户直接粘 script 文本——简单 + 准确)。Way b(whisper)只在用户**找不到 script 只有视频**时用。
---
## 这个 adapter 是干嘛的
把 mp4 / mov / mp3 等媒体文件转成文字 transcript,让 Claude 能读对标账号的稿子。
抖音 / B站 / YouTube 大多数视频**没有官方字幕**——拿稿子绕不开 ASR(语音转录)。这是为什么本 adapter 存在。
---
## 安装(一次性)
### 选项 Awhisper-cpp**推荐**——快、轻、纯 C++)
Mac M 系列芯片上一条 3 分钟视频转录 30-60 秒。
```bash
# 1. 装 whisper-cpp
brew install whisper-cpp
# 2. 装 ffmpegwhisper-cpp 依赖,从视频里抽音频)
brew install ffmpeg
# 3. 下载模型(中文推荐 medium 或 large-v3,准确度够 + 速度还行)
# whisper-cpp 第一次运行会自动下载,或手动:
mkdir -p ~/.whisper-cpp/models
cd ~/.whisper-cpp/models
# medium 模型 (~1.5GB)
curl -L -O https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin
```
### 选项 Bopenai-whisperPython 版,更慢但有 API 兼容性)
```bash
pip install openai-whisper
brew install ffmpeg
# 模型自动下载
```
### 选项 C:用云端 API(不需要本地模型)
`/cheat-learn-from` 暂不直接支持云端 API——如果你有 OpenAI / Azure / 阿里云的 ASR API key,可以自己改 `run.sh` 走云端。
---
## 用法
cheat-learn-from 自动调用,你不需要手动跑。但如果想手动测试:
```bash
# 转录单个视频
bash run.sh <video_path> <output_dir>
# 例:
bash run.sh ~/Desktop/对标账号/某视频.mp4 ~/my-channel/samples/对标账号/abc123/
# → 输出 ~/my-channel/samples/对标账号/abc123/transcript.md
```
## 输出格式
`transcript.md`
```markdown
# Transcript: <video filename>
**Source**: <video file path>
**Transcribed at**: <ISO timestamp>
**Engine**: whisper-cpp medium / openai-whisper large / etc.
**Duration**: <video length>
---
[纯文本转录,按段落分(不是字幕格式)]
```
> 注意 whisper 输出的字幕是按 **句子** 分行的(每句换行 + 时间戳)。
> run.sh 会去掉时间戳 + 把短句合并成段落,让 Claude 读起来像稿子,不是字幕表。
## 失败模式
| 症状 | 原因 | 处理 |
|---|---|---|
| `whisper-cpp: command not found` | 没装 | 跑 `brew install whisper-cpp` |
| `ffmpeg: command not found` | 没装 ffmpeg | 跑 `brew install ffmpeg` |
| 转录乱码 / 大量错字 | 视频是英文但用了中文模型,反之亦然 | 改 `run.sh``--language` 参数 |
| 转录慢(>10 分钟) | 用了 large 模型 + 没有 GPU/M-chip 加速 | 换 medium 模型 |
| Disk full | 模型文件大(large-v3 ~3GB | 用 medium~1.5GB)够用 |
## 稳定性等级
★★★★ — whisper 是开源标准 ASR,不会突然失效。模型更新自由,pin 版本无虞。
## 风险提示
- **TOS**:你转录**自己下载的对标账号视频**用于个人学习参考是合理使用;**不要**把转录结果再发布
- **隐私**:whisper 全部本地运行,不传任何数据到云端
## 文件清单
```
adapters/script-extraction/whisper/
├── README.md # 本文件
└── run.sh # cheat-learn-from 调用的 wrapper
```
## 与其他 adapter 的关系
-`adapters/perf-data/douyin-session/``adapters/trend-sources/*` 一样,是 cheat-on-content 的可选 adapter
- 只在 `/cheat-learn-from --way b` 时调用——Way a(粘文本)不需要
## 用户自己下载视频的说明
工具**不直接抓视频**——避免 TOS 风险 + 反爬维护成本。建议用:
- **抖音**:第三方下载器 / 抖音 PC 版 → 复制视频链接 → 粘进下载器
- **B站**[BBDown](https://github.com/nilaoda/BBDown) / [you-get](https://github.com/soimort/you-get)
- **YouTube**[yt-dlp](https://github.com/yt-dlp/yt-dlp)(最强大)
- **小红书**[xhs-downloader](https://github.com/JoeanAmier/XHS-Downloader)
下载后扔到 `samples/<benchmark-name>/<video-id>/source.mp4` 即可——cheat-learn-from 会自动找到。
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
#
# whisper adapter wrapper
#
# Called by /cheat-learn-from when user provides video file (Way b).
# Transcribes video → transcript.md (paragraph format, no timestamps).
#
# Usage:
# bash run.sh <video_path> <output_dir> [--lang <code>] [--model <name>]
#
# Defaults:
# --lang zh
# --model medium (whisper-cpp) or medium (openai-whisper)
#
# Output: writes transcript.md INTO output_dir.
# Exit codes:
# 0 = success
# 1 = whisper not installed
# 2 = ffmpeg not installed
# 3 = video file not found / unreadable
# 4 = transcription failed
set -uo pipefail
VIDEO="${1:-}"
OUTPUT_DIR="${2:-}"
LANG="zh"
MODEL="medium"
# Parse optional flags
shift 2 2>/dev/null || true
while [[ $# -gt 0 ]]; do
case "$1" in
--lang) LANG="$2"; shift 2 ;;
--model) MODEL="$2"; shift 2 ;;
*) echo "Unknown flag: $1" >&2; exit 4 ;;
esac
done
if [[ -z "$VIDEO" || -z "$OUTPUT_DIR" ]]; then
echo "Usage: bash run.sh <video_path> <output_dir> [--lang zh|en|...] [--model tiny|base|small|medium|large-v3]" >&2
exit 4
fi
if [[ ! -f "$VIDEO" ]]; then
echo "❌ Video file not found: $VIDEO" >&2
exit 3
fi
mkdir -p "$OUTPUT_DIR"
# Detect available whisper engine
ENGINE=""
if command -v whisper-cpp >/dev/null 2>&1; then
ENGINE="whisper-cpp"
elif command -v whisper >/dev/null 2>&1; then
ENGINE="openai-whisper"
else
cat >&2 <<EOF
❌ Neither whisper-cpp nor openai-whisper installed.
Install one:
Option A (recommended, fast): brew install whisper-cpp
Option B (Python, slower): pip install openai-whisper
Then re-run /cheat-learn-from.
See adapters/script-extraction/whisper/README.md for details.
EOF
exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "❌ ffmpeg not installed. Run: brew install ffmpeg" >&2
exit 2
fi
echo "[whisper] engine: $ENGINE | model: $MODEL | lang: $LANG"
echo "[whisper] transcribing: $VIDEO"
TMP_OUT=$(mktemp -d)
trap 'rm -rf "$TMP_OUT"' EXIT
# Transcribe — get raw text output
if [[ "$ENGINE" == "whisper-cpp" ]]; then
# whisper-cpp needs WAV input, convert via ffmpeg
AUDIO="$TMP_OUT/audio.wav"
ffmpeg -y -loglevel error -i "$VIDEO" -ar 16000 -ac 1 -f wav "$AUDIO" 2>&1 || {
echo "❌ ffmpeg failed to extract audio" >&2; exit 4;
}
whisper-cpp -m "$HOME/.whisper-cpp/models/ggml-${MODEL}.bin" -l "$LANG" -otxt -of "$TMP_OUT/out" "$AUDIO" >/dev/null 2>&1 || {
echo "❌ whisper-cpp failed (model file might be missing — check ~/.whisper-cpp/models/)" >&2; exit 4;
}
RAW_TXT="$TMP_OUT/out.txt"
else
# openai-whisper
whisper "$VIDEO" --language "$LANG" --model "$MODEL" --output_format txt --output_dir "$TMP_OUT" >/dev/null 2>&1 || {
echo "❌ openai-whisper failed" >&2; exit 4;
}
# openai-whisper names output as <video-basename>.txt
BASENAME=$(basename "$VIDEO" | sed 's/\.[^.]*$//')
RAW_TXT="$TMP_OUT/${BASENAME}.txt"
fi
if [[ ! -f "$RAW_TXT" ]]; then
echo "❌ No transcript produced" >&2
exit 4
fi
# Get video metadata for header
DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$VIDEO" 2>/dev/null | awk '{printf "%d:%02d", $1/60, $1%60}')
[[ -z "$DURATION" ]] && DURATION="unknown"
# Build output transcript.md
TRANSCRIPT_OUT="$OUTPUT_DIR/transcript.md"
{
echo "# Transcript: $(basename "$VIDEO")"
echo ""
echo "**Source**: $VIDEO"
echo "**Transcribed at**: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "**Engine**: $ENGINE / $MODEL"
echo "**Language**: $LANG"
echo "**Duration**: $DURATION"
echo ""
echo "---"
echo ""
# Raw text — whisper outputs one sentence per line; merge into paragraphs
# Heuristic: collapse to single paragraph (Claude can re-paragraph if needed)
awk 'BEGIN{ORS=""} {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0); if($0!=""){print $0; if(NR%5==0)print "\n\n"; else print " "}} END{print "\n"}' "$RAW_TXT"
} > "$TRANSCRIPT_OUT"
echo "✅ transcript.md written → $TRANSCRIPT_OUT"
exit 0
+60
View File
@@ -0,0 +1,60 @@
# adapters/trend-sources/aihot — AI 行业热点
**适合谁**AI 教程 / Builder / 工具号 / AI 行业评论。**不适合**普通生活/职场/文化垂类(用 trendradar-mcp.md)。
---
## 它是什么
[aihot.virxact.com](https://aihot.virxact.com) 的 Claude skill 适配。直接 curl 公开 REST API 拿中文 AI 行业每日精选 + 历史归档。
- **5 类内容**:模型 / 产品 / 行业 / 论文 / 技巧
- **数据新鲜度**:每天人工精选 + 实时增量;items 端点最近 7 天
- **无 auth**、无 API key、无 MCP server——就是装上 skill 直接用
## 装
```bash
UA='Mozilla/5.0 ... Chrome/124'
curl -fsSL -A "$UA" https://aihot.virxact.com/aihot-skill/install.sh | bash
```
装完后 Claude 会在 `~/.claude/skills/aihot/SKILL.md` 看到这个 skill,自动在用户问 AI 资讯时触发。
## cheat-seed / cheat-trends 怎么调
**不要直接 curl** —— 让 Claude 自然触发 aihot skill 即可:
| cheat-seed 场景 | 给 Claude 的内部指令 |
|---|---|
| Mode Ccontent_form 含 AI/教程/Builder | "调 aihot skill 拿今天 AI 圈精选条目,按 content_form 过滤后给 5 条" |
| Mode A 用户提到 AI 产品名("DeepSeek V5" | "调 aihot skill 用 q 参数搜该关键词最近 7 天动态" |
aihot skill 的 SKILL.md 已经详尽描述了端点 + 路由优先级(默认走精选不走日报)——cheat-seed 不需要重复写这套逻辑,**信任 aihot skill 自己的判断**。
## 输出格式契约
aihot skill 默认返回 markdown,按 5 类(模型/产品/行业/论文/技巧)分组。cheat-seed 收到后:
1.`content_form` 过滤掉不相关类别(如 opinion-video → 留行业 + 产品;tutorial-builder → 留模型 + 工具)
2. 用当前 rubric 粗筛 5 条最适合的
3. 转成 [candidate-schema.md](../../shared-references/candidate-schema.md) 的 schema 写入 `candidates.md`
## 失败模式
| 症状 | 处理 |
|---|---|
| 403 Forbidden | UA 没设浏览器格式——aihot skill 自己的 SKILL.md 第一段就警告了,正确装的话不会出问题 |
| 端点超时 / 5xx | 优雅降级到 trendradar-mcp 或 manual-paste;不抛异常 |
| 用户的 content_form 跟 AI 完全无关(如美食/妆教) | cheat-seed 应该**不调 aihot**——按 [data-source-routing.md](../../shared-references/data-source-routing.md) 的路由表 |
## 稳定性
★★★★★ — 公开 API,作者维护,无认证依赖。
---
## 与其他 adapter 的关系
- **vs trendradar-mcp.md**:互补不重叠。aihot 是 AI 垂直,trendradar 是综合。两者都启用时按 `content_form` 路由。
- **vs manual-paste**:永远的 fallback。aihot/trendradar 都失败时走 manual-paste。
+66
View File
@@ -0,0 +1,66 @@
# adapters/trend-sources/trendradar-mcp — 综合社会热点(MCP
**适合谁**:观点视频 / 时评 / 文化垂类 / 美食 / 职场 / 社会议题——**任何非 AI 垂直**的内容。
---
## 它是什么
[TrendRadar](https://github.com/sansan0/TrendRadar) 是 57k stars 的中文热点聚合监控(用 newsnow API 拉微博 / 知乎 / 抖音 / B 站 / 头条 等多平台)。它自带独立的 MCP server `trendradar-mcp`,暴露 25+ 个 tool。
cheat-on-content 把它当作 trend-sources adapter 之一——用户配 MCP server 后,cheat-seed / cheat-trends 自然能调。
- **多平台覆盖**:微博 / 知乎 / 抖音 / B站 / 头条 / 36kr / 等等
- **AI 增强工具**`analyze_topic_trend` 给爆火/衰退判定;`compare_periods` 给周环比;`analyze_sentiment` 给情感倾向
- **License**TrendRadar 本体是 GPL-3.0,但我们**只通过 MCP 协议调用**他们的 server,不构成 linking——无 GPL 传染
## 装
参考 TrendRadar 仓库的 [MCP 配置文档](https://github.com/sansan0/TrendRadar)。装好后用户的 Claude Code `.claude/settings.json``mcp__trendradar__*` 系列工具。
cheat-on-content 不打包 TrendRadar——用户自己装、自己保管 server 资源。
## cheat-seed / cheat-trends 调用的关键工具
| MCP 工具 | 用途 | 在哪调 |
|---|---|---|
| `mcp__trendradar__get_latest_news` | 拿最新热榜(最直接) | cheat-seed Mode C 主调 / cheat-trends 主调 |
| `mcp__trendradar__get_trending_topics` | 自动提取话题统计 | cheat-seed Mode C 备用 |
| `mcp__trendradar__analyze_topic_trend` | 单话题趋势分析(爆火/衰退) | cheat-seed Mode A 灰色场景 enrich(用户提了具体话题且同意拉数据) |
| `mcp__trendradar__compare_periods` | 周环比 / 月环比 | cheat-bump 升级 rubric 时作"用户领域是否变化"的弱信号(罕见用) |
| `mcp__trendradar__search_news` | 关键词搜索 | cheat-seed Mode A 用户提了关键词时 |
## 输出格式契约
TrendRadar MCP 返回 JSON / markdown。cheat-seed 收到后:
1. 解析 itemstitle / source / hot_score / snapshot_at / url
2. 按 [candidate-schema.md](../../shared-references/candidate-schema.md) 算稳定 id`sha256(source + normalized_title + url_path)[:12]`
3. 去重(参考 cheat-trends 的去重协议)
4. 用当前 rubric 粗筛
5. 写入 `candidates.md`
## 失败模式
| 症状 | 处理 |
|---|---|
| MCP server 没装 / 没启动 | cheat-seed 自动降级到下一个启用的源(如 aihot 或 manual-paste),不抛异常 |
| MCP 调用超时 | 30 秒后超时,提示用户"trendradar 慢,要等还是切别的源" |
| newsnow 上游 API 改了 | TrendRadar 维护者会修;用户跟着升级 |
## 稳定性
★★★★ — 取决于 TrendRadar 项目活跃度(57k stars,活跃)+ newsnow 上游稳定性。
---
## 与其他 adapter 的关系
- **vs aihot.md**:互补不重叠。trendradar 是综合社会,aihot 是 AI 垂直。两者都启用时按 `content_form` 路由(详见 [data-source-routing.md](../../shared-references/data-source-routing.md)
- **vs manual-paste**:永远的 fallback。两个 API 都失败时走 manual-paste
## 给 TrendRadar 团队的话
如果你是 TrendRadar 维护者看到这份 adapter doc——感谢你把多平台聚合做成 MCP server。cheat-on-content 是你们项目的"内容生产侧下游"——用户用 TrendRadar 知道发生了啥,用 cheat-on-content 把这个变成可校准的内容预测循环。互补不替代。
欢迎 cross-link[github.com/XBuilderLAB/cheat-on-content](https://github.com/XBuilderLAB/cheat-on-content)。
+76
View File
@@ -0,0 +1,76 @@
# Adapter: weibo-hot(微博热搜)
被以下 skill 调用:`cheat-seed` Phase 2a、`cheat-trends`
> **当前状态**schema only。实际 fetch 实现归 batch 3。/cheat-seed 在 stub 期由 Claude 通过 `WebFetch` 直接抓取 + 解析(见下方"过渡期实现")。
---
## 适用场景
- **`cheat-seed` 默认 source 之一**——cold-start 用户的第一次选题种子
- **`cheat-trends` 可选 source**——日常补充候选池
最贴合:时事评论、社会议题、热点解读类观点视频。
## 依赖
- 公开端点,**无需 cookie**
- 端点:`https://s.weibo.com/top/summary?cate=realtimehot`HTML 页面)
- 备选:`https://weibo.com/ajax/side/hotSearch`(JSON,部分时段返回 401,不稳定)
## Fetch 接口
```
fetch(limit: int = 50) -> List[Candidate]
```
返回符合 [shared-references/candidate-schema.md](../../shared-references/candidate-schema.md) 的 items 列表。
字段映射:
- `id``sha256("weibo-hot|" + normalized_title + "|" + url_path)[:12]`
- `title`:热搜词
- `source``"trend:weibo-hot"`
- `snapshot_text`:热搜词 + (如有)官方标签 + 简短摘要(自动从热搜详情页抓 1-2 句)
- `snapshot_at`:抓取时间 ISO 8601
- `url``https://s.weibo.com/weibo?q=<encoded_keyword>`
- 其他字段:null(抓取阶段不打分;由调用方 cheat-score 处理)
## 失败模式
| 症状 | 处理 |
|---|---|
| HTML 结构变化导致解析失败 | 返回空列表 + stderr 写明 "weibo HTML 结构变化,参考 adapters/trend-sources/weibo-hot.md 自行修补" |
| 端点 503 / 限流 | 返回空列表 + 报告 |
| 网络不可达 | 返回空列表 + 报告 |
**优雅降级**:单次失败不抛异常——调用方(cheat-seed / cheat-trends)会用其他 sources 兜底。
## 稳定性等级
★★★ — 公开端点,但微博偶尔调整页面结构 + 有反爬(同一 IP 短时间高频抓取会被限流)。
建议节流:`/cheat-seed` 默认每用户每天 ≤ 3 次抓取——cold-start 阶段不需要更高频。
## 过渡期实现(stub
在 batch 3 写专用 adapter 实现前,`/cheat-seed` 在调用本 source 时直接由 Claude 通过 `WebFetch` 工具抓 `https://s.weibo.com/top/summary?cate=realtimehot`,从 HTML 中提取 top 50 热搜标题。具体由 cheat-seed 的 Phase 2a 处理:
```
WebFetch("https://s.weibo.com/top/summary?cate=realtimehot",
"提取 top 50 热搜的标题文本,每行一个,按热度降序")
```
如果 WebFetch 返回的内容能识别出 ≥10 条热搜 → 视为成功;否则视为失败,跳过本 source。
## 风险提示
- 微博热搜内容**经常含政治敏感 / 娱乐八卦**议题——/cheat-seed Phase 1 Q3 的"红线"过滤至关重要
- 部分热搜词太短(5-10 字)缺少上下文——Claude brainstorm 时需要展开
- 热搜的"热度"分数与"适合做观点视频的程度"**不正相关**——粗打分时不要直接把热度当 composite 输入
## 相关 adapter
- [zhihu-hot.md](zhihu-hot.md) — 议题深度更高,论说类更匹配
- bilibili-popular.md(待)— 偏年轻议题
- thirdparty-paid.md(待)— 新榜 / 飞瓜,付费但稳定
+83
View File
@@ -0,0 +1,83 @@
# Adapter: zhihu-hot(知乎热榜)
被以下 skill 调用:`cheat-seed` Phase 2a、`cheat-trends`
> **当前状态**schema only。实际 fetch 实现归 batch 3。/cheat-seed 在 stub 期由 Claude 通过 `WebFetch` 直接抓取(见下方"过渡期实现")。
---
## 适用场景
- **`cheat-seed` 默认 source 之一**——cold-start 用户的第一次选题种子
- **`cheat-trends` 可选 source**——日常补充候选池
最贴合:论说 / 议题讨论 / 知识科普类观点视频。知乎话题平均比微博更"可讨论"——一个标题就含问题与立场,省了 brainstorm 一半工。
## 依赖
- 公开端点,**无需登录**
- 端点:`https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50&desktop=true`JSON
- 备选:`https://www.zhihu.com/billboard`HTML,可作为兜底)
## Fetch 接口
```
fetch(limit: int = 50) -> List[Candidate]
```
返回符合 [shared-references/candidate-schema.md](../../shared-references/candidate-schema.md) 的 items 列表。
字段映射:
- `id``sha256("zhihu-hot|" + normalized_title + "|" + url_path)[:12]`
- `title`:知乎问题标题
- `source``"trend:zhihu-hot"`
- `snapshot_text`:问题标题 + 高赞答案前 200 字摘要(可选——抓不到就只用标题)
- `snapshot_at`:抓取时间 ISO 8601
- `url`:知乎问题 URL(如 `https://www.zhihu.com/question/<id>`
- 其他字段:null
## 失败模式
| 症状 | 处理 |
|---|---|
| API 端点变更 | 切换到 `/billboard` HTML 兜底 |
| API 返回需要登录(403) | 返回空列表 + 报告 |
| 网络不可达 | 返回空列表 + 报告 |
**优雅降级**:失败不抛异常——调用方有其他 sources 兜底。
## 稳定性等级
★★★★ — 知乎 API 比微博稳定;JSON 端点改动频率低于微博 HTML。
建议节流:`/cheat-seed` 默认每用户每天 ≤ 3 次抓取。
## 过渡期实现(stub
在 batch 3 写专用 adapter 前,`/cheat-seed` 通过 `WebFetch`
```
WebFetch("https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50&desktop=true",
"解析 JSON 中 data 数组的每个 item,提取 target.title_area.text 和 target.url,最多 50 条")
```
如果返回结构识别失败 → 切换到 `/billboard` HTML 抓取兜底。
## 内容特点(影响 brainstorm 质量)
知乎热榜的标题结构通常是 **完整的疑问句**"如何看待 X"、"为什么 Y"、"X 的本质是什么"),比微博热搜的关键词更适合直接转化为观点视频选题。
但要注意:
- 一些标题太具体("X 公司裁员事件")→ Claude brainstorm 时要做"个案 → 普遍"的抽象提升
- 一些标题太"知乎腔"(学术化、长难句)→ Claude brainstorm 时要做"知乎话术 → 短视频钩子"的转译
## 风险提示
- 知乎热榜偶现政治敏感议题——/cheat-seed Phase 1 Q3 的"红线"过滤必要
- 部分热榜话题已被知乎大 V 高密度覆盖,做视频时差异化不易——粗打分时建议提示用户"该话题已饱和,需要差异化角度"
## 相关 adapter
- [weibo-hot.md](weibo-hot.md) — 议题更广但更碎片化,时事评论类更匹配
- bilibili-popular.md(待)— 视频内容直接对照参考
- thirdparty-paid.md(待)— 付费稳定数据源
+179
View File
@@ -0,0 +1,179 @@
<h1 align="center">
<img src="logo.svg" alt="Cheat on Content" width="720">
</h1>
<h2 align="center">Cheat on Content</h2>
<p align="center">
<a href="../README.md"><strong>English</strong></a>
&nbsp;·&nbsp;
<strong>简体中文</strong>
</p>
<p align="center">
<a href="https://watcha.cn/products/cheat-on-content">
<img src="guancha-no1.svg" alt="Watcha Hot List · 观猹热榜 · #1" width="328">
</a>
</p>
<p align="center">
<a href="../CHANGELOG.md"><img src="https://img.shields.io/badge/version-v0.1.0-orange" alt="Version"></a>
&nbsp;
<a href="../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
</p>
<p align="center">
网红作弊器 — 把每一条内容变成可校准的实验。
</p>
<p align="center">
你正在读这段话——这个 skill 预测过了。<br>
它把博主的每一次"我感觉这条会爆"变成可校准的实验。<br>
我用它一个月涨粉 100w。它说我会写这段,我就写了。<br>
你停下来思考"这是不是真的"——也在它的预测里。
</p>
---
## 🎬 它真正在干什么
大部分创作者活在同一个赌局里:
> 发布 → 数据出来 → 学不到东西 → 下一次继续赌
跑过 200 条的博主跟跑过 1 条的差距不到 10%——因为他们没在每次赌局后**记账**。
**网红外挂**让每一次判断都被记录、被复盘、被吸收进下一次:
📊 打分 → 🎯 盲预测 → 🚀 发布 → 📈 T+3 天复盘 → 🧬 进化你的评分公式
这不是 motivation,是 **compounding**——你不复盘的每一篇,都是在折损"看见自己"的能力。
跑一个月 = 你有了一份**只属于你的爆款公式**。
跑三个月 = 你比刚开始的自己强 10 倍。
---
## 🌀 起源
> 我以前完全不信宿命论。直到这个 skill 让我拍了一条视频——它甚至预测了这条视频能跑多少流量。
>
> 我试图用集体观测证伪它,把这事告诉观众,希望量子坍缩改变轨迹。
>
> 数据是准的。
>
> 我没逃出宿命论,只是从一阶跳到了二阶。
>
> 如果连我的觉醒、连观众的观测都在它的预设里——
> 此时此刻你读到这段:是出于好奇,还是正在完成算法的最后一次落位?
>
> —— 创作者本人
---
## ⚖️ 它和别的"创作工具"哪里不一样
| 别人 | 这个 |
|---|---|
| 给你"灵感" | 让你**自己的灵感被量化** |
| AI 帮你写 | AI 帮你**判**——稿子还是你的 |
| 一发发 10 个版本 A/B 测 | 一发就**赌**——把判断写下来,数据出来对账 |
| 静态数据看板 | **会进化的评分公式**——你三个月后的 rubric 已经不是初始版 |
一句话:别的工具帮你"产出更多",这个工具帮你"判得更准"。
---
## 🤔 那 ChatGPT / 豆包 / DeepSeek 不是也能干这个?
那是**通用助手**——对所有人说同样的话。你问"我这条会爆吗",它的答案是从全网平均经验拟合出来的,跟你的账号没关系。明天再问一遍,答案还是上次那个——**它不记得你,更不会因为你而变**。
这套是**你自己的运营专家**,只服务你这一个账号:
- 评分公式从**你的**历史数据反推,不是通用训练分布
- 每发一条它就更新一次对你账号的理解——三个月后判断准度比刚开始强 10 倍(**自动进化**)
- 它知道你的对标账号、你的发布 cadence、你最近三次为什么扑——这些 ChatGPT 第一句话就忘了
通用 LLM 帮所有人;这套帮你**这个**账号。
---
## 🛡️ 它怎么让循环真的能进化
📝 **每条都留底**:发布前打分、写预测,全程存档。三天后回来对账——你哪里准、哪里偏,**一目了然**,不再是模糊的"感觉这次没发好"。
🔁 **越用越准**:连续三次同方向偏差,工具自动催你升级评分公式。**你不主动它也催**。
🛡️ **升级有刹车**:换公式必须用新公式重判所有历史样本,能比旧公式更准才放行;还要跨模型独立审一次——**防你自己骗自己**。
🪒 **rubric 是工作台不是博物馆**:被推翻的观察删,被吸收的也删。永远只放当下最有用的。
---
## 📦 安装
```bash
git clone https://github.com/XBuilderLAB/cheat-on-content.git
cd cheat-on-content
bash install.sh
```
> ⚠️ **从 v0.x 升级?** `git pull` 后在你的内容项目里跑 `/cheat-migrate`。**1.3 → 1.4 是 blind channel 完整性 BREAKING 修复**——拆分 `rubric_notes.md` 防止 blind sub-agent 通过白名单读到实绩。不跑迁移的话 blind 打分会持续标 `non_blind_warning`。详见 [CHANGELOG](../CHANGELOG.md) 和 [migrations/1.3-to-1.4.md](../migrations/1.3-to-1.4.md)。
14 个子 skill 软链接到你 agent 的 skill 目录。装一次,所有内容项目都能用。
**支持的 agent**Claude Code(默认)· Codex`bash install.sh --codex`)· 两个都装(`bash install.sh --all`
> 冻结版本:`bash install.sh --copy` / `bash install.sh --codex --copy`
>
> 卸载:`bash uninstall.sh` / `bash uninstall.sh --codex`(不动你的内容数据)
---
## 🚀 第一次跑
在你的内容项目目录里打开支持 skill 的 agent,说:
```
初始化 cheat-on-content
```
5 个 yes/no 搞定 onboarding。**强烈建议导对标账号**——5-10 条样本 → 工具立刻有 anchor,不然前 5 篇预测精度 ±50%。
---
## ⚡ 日常用法
```
打分这篇 scripts/<...>.md → 评分
启动预测 scripts/<...>.md → 盲预测 + 决策日志
拍了 scripts/<...>.md → 建 video folder + buffer +1
已发布 https://... → buffer -1
复盘 videos/<...>/ → T+3d 数据回收 + 复盘
状态 / 抓热点 / 找选题 / 升级 rubric / 找对标
```
支持 hook 的 agent 每次开会话自动报告 buffer + 待复盘 + top 候选——你不用主动问。其他 agent 直接说 `状态` 即可。
完整工作流 + 子 skill 细节见 [SKILL.md](../SKILL.md)。
---
## 📈 Star History
<a href="https://star-history.com/#XBuilderLAB/cheat-on-content&Date">
<img src="star-history.svg" alt="Star History Chart" width="720">
</a>
---
## 📜 License
MIT。商用、改造、闭源接入都行。
---
*这是作弊吗?计算器也是。Google 也是。*
*未来从不奖励努力——它只奖励先看见规律的人。*
*你看到这一行——也是它预测的。*
+19
View File
@@ -0,0 +1,19 @@
<?xml version='1.0' encoding='utf-8'?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 55" width="228" height="55" role="img" aria-label="Watcha Hot List · 观猹热榜 · #1 Trending · 热榜第一">
<title>Watcha Hot List · 观猹热榜 — #1 Trending · 热榜第一</title>
<rect stroke="#A87600" stroke-width="1" fill="#FFFFFF" x="0.5" y="0.5" width="227" height="54" rx="10"/>
<!-- Laurel wreath (generic laurel-icon path, MIT, from upstream asset). -->
<svg viewBox="0 0 80 80" width="48" height="45" x="10" y="6">
<path fill="#A87600" fill-rule="evenodd" d="M70.71,40.31C75.74,44.3,80,37.86,80,37.86s-5.64-2.17-8.55,0.61c0.59-1.62,1.02-3.31,1.28-5.01c4.08,2.16,6.44-2.95,6.44-2.95s-4.41-0.97-6.26,1.4c0.08-0.91,0.12-1.82,0.1-2.73c-0.01-0.36-0.02-0.73-0.05-1.09c2.96-3.68-1.73-6.99-1.73-6.99s-2.14,5.09,0.98,7.09c0.02,0.33,0.03,0.66,0.03,1c0.01,0.76-0.03,1.52-0.1,2.27c-0.85-2.69-4.91-3.69-4.91-3.69s-0.13,5.78,4.68,5.48c-0.28,1.69-0.73,3.35-1.34,4.95c-0.19-4.03-5.79-6.33-5.79-6.33s-1.33,7.55,5.01,8.16c-0.38,0.8-0.8,1.57-1.25,2.32c-0.56,0.95-1.21,1.84-1.89,2.71c0.97-3.99-3.96-7.72-3.96-7.72s-3.18,6.94,2.73,9.15c-0.38,0.43-0.8,0.81-1.2,1.21c-0.21,0.2-0.43,0.38-0.64,0.58l-0.32,0.29c-0.11,0.09-0.22,0.18-0.33,0.27l-0.67,0.54l-0.7,0.51c-0.08,0.05-0.16,0.11-0.23,0.16c1.62-3.42-2.07-7.77-2.07-7.77s-4.21,5.55,0.49,8.78c-1.34,0.79-2.74,1.45-4.2,1.98c1.91-2.59-0.23-6.89-0.23-6.89s-4.66,3.77-1.52,7.46c-1.15,0.33-2.33,0.57-3.51,0.74c1.46-1.68,0.55-4.83,0.55-4.83s-3.7,2.03-2.18,5c-0.52,0.03-1.05,0.07-1.57,0.06c-0.29,0-0.57,0.01-0.86,0l-0.86-0.04c-0.85-0.06-1.7-0.15-2.54-0.28l0.68-0.27l0.42-0.17l0.41-0.19l0.82-0.38c0,0,0.01,0,0.01,0c0.39-0.18,0.55-0.65,0.37-1.03c-0.18-0.39-0.65-0.55-1.03-0.37l-0.04,0.02l-0.77,0.37l-0.39,0.18l-0.39,0.16l-0.79,0.33l-0.8,0.29l-0.4,0.14l-0.41,0.12L40,53.6l-0.51-0.15l-0.41-0.12l-0.4-0.14l-0.8-0.29l-0.79-0.33l-0.39-0.16l-0.39-0.18l-0.77-0.37l-0.04-0.02c0,0,0,0-0.01,0c-0.39-0.18-0.85-0.01-1.03,0.38c-0.18,0.39-0.01,0.85,0.38,1.03l0.82,0.38l0.41,0.19l0.42,0.17l0.68,0.27c-0.84,0.14-1.69,0.22-2.54,0.28l-0.86,0.04c-0.29,0.01-0.57,0-0.86,0c-0.53,0.01-1.05-0.03-1.57-0.06c1.51-2.98-2.18-5-2.18-5s-0.92,3.15,0.55,4.83c-1.19-0.16-2.36-0.41-3.51-0.74c3.15-3.7-1.52-7.46-1.52-7.46s-2.14,4.31-0.23,6.89c-1.46-0.53-2.86-1.19-4.2-1.98c4.7-3.22,0.49-8.78,0.49-8.78s-3.69,4.34-2.07,7.77c-0.08-0.05-0.16-0.1-0.23-0.16l-0.7-0.51l-0.67-0.54c-0.11-0.09-0.23-0.18-0.33-0.27l-0.32-0.29c-0.21-0.19-0.43-0.38-0.64-0.58c-0.4-0.4-0.82-0.79-1.2-1.21c5.91-2.21,2.73-9.15,2.73-9.15s-4.93,3.73-3.96,7.72c-0.68-0.86-1.33-1.76-1.89-2.71c-0.46-0.75-0.87-1.53-1.25-2.32c6.33-0.61,5.01-8.16,5.01-8.16s-5.6,2.31-5.79,6.33c-0.61-1.6-1.06-3.26-1.34-4.95c4.81,0.3,4.68-5.48,4.68-5.48s-4.05,0.99-4.91,3.69c-0.07-0.76-0.1-1.51-0.1-2.27c0-0.33,0.01-0.66,0.03-1c3.11-2.01,0.98-7.09,0.98-7.09s-4.69,3.31-1.73,6.99C7,28.46,6.99,28.82,6.98,29.18c-0.02,0.91,0.01,1.82,0.1,2.73c-1.84-2.38-6.26-1.4-6.26-1.4s2.37,5.11,6.44,2.95c0.26,1.71,0.69,3.39,1.28,5.01C5.64,35.69,0,37.86,0,37.86s4.26,6.43,9.29,2.45c0.39,0.87,0.83,1.72,1.31,2.54c0.47,0.83,1.01,1.63,1.58,2.4C8.71,43.7,4.11,47,4.11,47s5.7,5.1,9.56,0.08c0.04,0.04,0.07,0.08,0.11,0.12c0.39,0.45,0.82,0.87,1.24,1.3c0.21,0.21,0.44,0.41,0.66,0.61l0.33,0.3c0.11,0.1,0.23,0.19,0.34,0.29l0.69,0.57l0.23,0.17c-3.34-0.34-6.58,3.29-6.58,3.29s6.19,3.47,8.69-1.83c1.2,0.75,2.47,1.41,3.78,1.96c-2.76,0.6-4.62,4.13-4.62,4.13s5.89,1.62,6.98-3.26c1.03,0.32,2.07,0.58,3.13,0.78c-1.63,0.99-2.39,3.38-2.39,3.38s4.31,0.39,4.61-3.07c0.07,0.01,0.14,0.02,0.21,0.02c0.6,0.04,1.2,0.1,1.8,0.09c0.3,0,0.6,0.02,0.9,0.01l0.9-0.03c1.2-0.07,2.41-0.18,3.59-0.42l0.45-0.08c0.15-0.03,0.29-0.07,0.44-0.1L40,55.13l0.81,0.19c0.15,0.03,0.29,0.07,0.44,0.1l0.45,0.08c1.18,0.23,2.39,0.35,3.59,0.42l0.9,0.03c0.3,0.01,0.6-0.01,0.9-0.01c0.6,0,1.2-0.06,1.8-0.09c0.07-0.01,0.14-0.02,0.21-0.02c0.31,3.45,4.61,3.07,4.61,3.07s-0.76-2.39-2.39-3.38c1.06-0.2,2.11-0.46,3.13-0.78c1.09,4.88,6.98,3.26,6.98,3.26s-1.86-3.52-4.62-4.13c1.31-0.55,2.57-1.21,3.78-1.96c2.5,5.3,8.69,1.83,8.69,1.83s-3.24-3.63-6.58-3.29l0.23-0.17l0.69-0.57c0.11-0.1,0.23-0.19,0.34-0.29l0.33-0.3c0.22-0.2,0.45-0.4,0.66-0.61c0.42-0.43,0.85-0.84,1.24-1.3c0.03-0.04,0.07-0.08,0.11-0.12C70.19,52.1,75.89,47,75.89,47s-4.6-3.31-8.08-1.75c0.57-0.77,1.11-1.56,1.58-2.4C69.88,42.03,70.31,41.18,70.71,40.31z"/>
</svg>
<!-- "1" centered in wreath -->
<text x="34" y="35" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#A87600" text-anchor="middle">1</text>
<!-- Top label (English first, then Chinese) -->
<text x="64" y="20" font-family="Arial,'PingFang SC','Microsoft YaHei',sans-serif" font-size="10" font-weight="400" fill="#A87600">Watcha Hot List · 观猹热榜</text>
<!-- Main heading (English first, then Chinese) -->
<text x="64" y="40" font-family="Arial,'PingFang SC','Microsoft YaHei',sans-serif" font-size="15" font-weight="700" fill="#A87600">#1 Trending · 热榜第一</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+53
View File
@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" width="745" height="285" viewBox="0 0 745 285" role="img" aria-label="$ fatesnail — cheat on content">
<!-- background -->
<rect width="745" height="285" rx="10" fill="#0e0f11"/>
<!-- title bar (just slightly distinguished) -->
<rect width="745" height="40" rx="10" fill="#0e0f11"/>
<rect y="32" width="745" height="8" fill="#0e0f11"/>
<!-- traffic lights -->
<circle cx="22" cy="20" r="6" fill="#ff5f56"/>
<circle cx="40" cy="20" r="6" fill="#ffbd2e"/>
<circle cx="58" cy="20" r="6" fill="#27c93f"/>
<!-- titlebar text -->
<text x="372" y="25"
font-family="ui-monospace, SF Mono, Menlo, Monaco, Consolas, monospace"
font-size="14"
fill="#9b9893"
text-anchor="middle"
letter-spacing="1">— fatesnail · zsh —</text>
<!-- main: $ fatesnail -->
<text x="40" y="120"
font-family="ui-monospace, SF Mono, Menlo, Monaco, Consolas, monospace"
font-size="58"
font-weight="700"
fill="#e8e2d3">$ fatesnail</text>
<!-- cursor block -->
<rect x="455" y="78" width="26" height="50" fill="#e8e2d3"/>
<!-- detail line 1 -->
<text x="40" y="172"
font-family="ui-monospace, SF Mono, Menlo, Monaco, Consolas, monospace"
font-size="17"
fill="#9b9893"> predict ▸ post ▸ recon ▸ evolve</text>
<!-- detail line 2 -->
<text x="40" y="198"
font-family="ui-monospace, SF Mono, Menlo, Monaco, Consolas, monospace"
font-size="17"
fill="#9b9893"> rubric.v0.42 — calibrated for you</text>
<!-- comment line -->
<text x="40" y="248"
font-family="ui-monospace, SF Mono, Menlo, Monaco, Consolas, monospace"
font-size="22"
font-weight="700"
fill="#e8e2d3">// cheat on content</text>
<!-- bottom underline -->
<line x1="40" y1="265" x2="705" y2="265" stroke="#3a3935" stroke-width="2" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

+235
View File
@@ -0,0 +1,235 @@
# 写作 Pattern 沉淀(**示例 / 参考——不是用户模板**)
> ⚠️ **本文件是参考样本,不是用户模板**。你的项目里的 `script_patterns.md` 是从 `templates/script_patterns.template.md` 复制过来的**抽象骨架**,初始为空。
>
> 这份 example 是给你看"一份完全填好的 script_patterns.md 长什么样"——基于「中文观点视频博主,25+ 已发视频」的真实反推。
>
> **不要照抄**——你的频道 / 调性 / 受众完全不同,里面的 pattern 大半对你无效。从你自己的复盘里慢慢累积**你的** pattern 才是工具的核心价值。
>
> 想用本文件中的某条 pattern → 在你的 script_patterns.md 里手动加,并标 `**Imported from example, untested on my channel**` —— 跑 ≥2 次复盘验证后再去掉这个标记。
>
> ---
>
> rubric_notes.md 教 Claude **怎么打分**script_patterns.md 教 Claude **怎么写**。两者解耦——分数高的稿子不一定写得好(rubric 漏掉 MS / TS),写得好的稿子不一定分数高(结构创新跑出 pattern 之外)。
>
> 示例数据来自「视频分析」项目(中文观点视频,25+ 已发样本反推)。
---
## ⚠️ Pattern 是 toolbox 不是 mold(核心 meta 原则)
下面所有 pattern 都是**从已发样本反推的"路径之一"**,不是默认应该 apply 的模板。
**反例**:每篇都"3 段式 + 致谢段 + 互动钩子"——账号上限只是"做得稳定的中等爆款"。**真正的爆款来自结构创新**——参考博主的"她不一样"模因爆发就在 pattern 之外。
写稿前**永远先回答**:这篇文章的最强属性是什么?再决定用什么 pattern。详见下方"结构选型 cheat sheet"。
---
## 结构选型 cheat sheet(按文章属性匹配)
| 文章属性 | 适合的结构 | 已发样本(你自己填)| 状态 |
|---|---|---|---|
| **强 metaphor**(具体载体可类比一切)| **metaphor 优先**——开头场景代入比喻 → 主体翻译 → 元戏仿致谢 | 例:仓鼠 ✓ | 示例 |
| **强时间维度**(一天循环 / 阶段演变)| **时间线叙事**——时间点切换开场 → 命名概念 → 数据穿插 | (待填)| 示例 |
| **强讽刺戏仿(SAT=5**| **脱口秀向**——单事件段子开场 → punchline → 扩展 | (待填)| 示例 |
| **强数据反差**(冷数据暴力)| **数据反转开场**——具体数字 → 反直觉对比 → 数据驱动叙事 | 例:房价 ✓ / 老板废话 ✓ | 示例 |
| **多个真实事件 / 案例**| **案例驱动**——标志性事件开场 → 抽象命名 → 三段失效 | (待填)| 示例 |
| **多个并行概念**| **3 段工业流水**——IS 戏仿 → 3 段命名 → 致谢 | (待填)| 示例 |
| **强场景代入**(半夜翻聊天 / 评论区现场)| **第二人称代入** → 反转 → 数据 → 三段 | 例:停止期待 ✓ | 示例 |
| **强可挪用句式**(高 MS| **模板核心**——句式贯穿全稿 → 互动钩子激活模板 | 例:停止期待"她不一样" ✓ | 示例 |
### 用法
写稿前先问自己:
1. 这篇文章最强的属性是什么?
2. 表里对应哪种结构?
3. 如果属性多元,哪种结构能让最强属性发挥到最大?
---
## 核心 pattern 库
### Pattern 1:开头钩子三选一(前 3 秒)
**绝不**用"近年来 / 大家好 / 今天聊一聊"。三种已验证模式:
**A. 第二人称场景代入 → 反转**(适用:原文有具体 metaphor / 实验对象 / 案例)
> 你在路上碰到一只 X / 冲动掏了钱就 Y / 但是这件事和 Z / 本质上是同一件事
**B. 戏仿原文体的影响声明**(适用:原文 IS 本身就锐利时直接照念)
> 本研究仅影响半夜 3 点还在 X 的人
**C. 直接甩数据 / 反转性观察**(适用:原文有强数据点)
> 作者扒了 X / 算出了几个数据 / 平均 Y / 但 Z 只有 W
---
### Pattern 2:主体压缩到「严格 3 个观察」
跑得好的稿子都遵循 `作者提出了 X 个观察/概念/指标` 的三段式:
| 视频示例 | 三个观察 |
|---|---|
| 仓鼠 | 资源错位 / 义务付出化 / 天性污名化 |
| 停止期待 | QOI(每天想的时间占比)/ WIC(万一呢系数)/ 间歇性强化 |
| 老板废话 | 语义黑洞 / TOP10 排行(开头第1+末尾第10)/ 万能公式 |
**严格 3 个**——不是 4 个不是 5 个。原文有 4-7 个概念时砍到 3 个——挑最具命名力 + 最具可挪用性的。
**TOP10 列表特殊处理**:保留头 + 尾 + "剩下你们自己去看"——不要平铺 10 个。
---
### Pattern 3:学术包装的剥离 / 保留
| 必须保留 | 必须剥离 |
|---|---|
| 自创概念名(QOI / WIC / 语义黑洞)→ MS 来源 | 模型名(缩写大于 3 字符的) |
| 关键精确数字(83.6% / 7.4 条)| 样本数量铺垫("312 个参与者..." |
| 致谢段(如果它本身就是 MVP)| 文献综述("Vlahovic 2012... König 2019..." |
| 反转性数据点("系统崩溃概率提升 412%")| 局限性讨论 |
| 作者原话的金句段 | 实验流程细节(除非是钩子) |
**判断原则**:观众能不能在评论区**复述/挪用**这个东西?能 → 留;不能 → 砍。
---
### Pattern 4"我"表达节点(emotional 标记)
跑得好的稿子里 "我" 出现 0-2 次,**永远在 emotional 高潮处**——给观众"该共鸣了"的提示信号。
**Pattern**:每出现 1 次"我",必须紧跟一个高强度信息点。**不要空说"我觉得"**。
---
### Pattern 5:双声道结构(作者 + 用户旁白)
每个稿子**不是用户独白**,而是 `作者说... 用户加注...` 的双声道:
- 作者的话引出**框架**"作者提出..."
- 用户的话做**接地翻译**"打工人版" / "意思就是说" / "换句话说" / "说人话就是"
- 用户的话做**emotional 标记**"最让我破防的" / "全文最反转的" / "更恐怖的是"
**永远不要**让一段超过 4 行没有用户接地词。
---
### Pattern 6:致谢段不是必选
判断原则:只有当致谢段**本身就是 MVP 句**(即可独立成模因)时才完整引用。否则用文末核心金句替代致谢段的功能。
致谢段 = 参考博主某些视频的核心传播载体,但其他视频没致谢段也跑出 30w+。**MVP 句才是必杀**。
---
### Pattern 7:标准化结尾结构
```
[reflective MVP 段(作者原话或用户提炼)]
[可选:用户的个人化诠释]
[outro: "任何解说代替不了原文 / 下课"]
```
`任何解说代替不了原文 / 下课` 是固定 outro(参考博主的招牌)。**你应该建立你自己的招牌 outro**——观众认那个就形成了品牌。
---
### Pattern 8:句子长度 / 段落节奏(**最容易忽视**)
**写稿用段落版**(每段 100-300 字,逗号、句号、自然句),**不要写一行一行的字幕格式**——那是用户拍完后剪映自动断的字幕,**不是写作时的形态**。
- **写作形态(draft-v0.md / script.md**:完整段落
- **字幕形态(视频拍完)**:剪映自动断成 5-15 字 / 行
- cheat-seed 写 draft 时**只输出段落版**
**段落内的句子节奏**
- **短句为主**——避免复合句、避免"其..."、"以..."这种文言连接
- 句号 + 逗号正常使用,不要怕标点
- **多用语气词**:啊 / 呢 / 哎(句尾)
**反例**(论文体):
> 在异步社交矩阵中,单音节拟声词"哈"及其多阶重复序列已演化为一种关键的社交润滑协议。
**应该这样写**(短句段落):
> 你有没有想过,聊天的时候打几个哈最合适。哈哈不行,哈哈哈才行,哈哈哈哈又怕被说装,哈哈哈哈哈哈哈哈又怕被说阴阳。
---
### Pattern 9:词汇风格 cheat sheet
**高频用词**(参考博主已验证有效):
- 接地翻译:打工人版 / 意思就是说 / 换句话说 / 说人话就是 / 简单点说
- 反转标记:但是 / 更恐怖的是 / 更搞的是 / 全文最反转的一段
- emotional 标记:破防 / 头皮发麻 / 让我读了好几遍 / 真的是
- 节奏词:啊 / 这就 / 你看
**避免**
- 论文体连接词:"其次" / "本研究" / "综上所述" / "因此"
- 形容词堆砌:"极其精准的""非常深刻的"——直接给名词
- 冗长定语:"一种基于…的…现象"
**你应该建立你自己的词汇风格**——上面是参考博主的,你的频道调性可能完全不同。
---
### Pattern 10:时长目标
跑得好的视频时长(参考博主,3-5min 区间):
- 1:5739w)—— 最短
- 2:1111w
- 2:5271w
- 3:00124w
**不要超过 3:30**(在 v2 起点的 NA=4 权重下)。**你的账号可能不同**——按 cheat-init 配的 typical_duration_seconds 调。
---
## 用户改稿历史观察(**持续追加,cheat-retro 复盘后建议补充**
> 每次复盘发现"用户改了 X,流量出现明显 Y 影响"时,cheat-retro 会建议在这里追加一行。
| 视频 | 用户砍掉了什么 | 用户加了什么 | 流量影响 |
|---|---|---|---|
| 示例:停止期待 | EWDM 模型名 + 312 人样本铺垫 | — | T+7d 71.1w(高)|
| 示例:老板废话 | TOP10 中间 8 条 + 致谢段 | — | T+4d 39.6w(高)|
**结论**(参考博主):用户**系统性砍**列表型枚举 + 学术铺垫 + 模型缩写。下次写初稿时**不要塞这些再让用户砍**——直接砍掉。
**你的频道结论**(待填,跑完几次复盘后填):
- ……
---
## 新发现的 Pattern(编号续)
> cheat-retro 复盘时发现新现象(用户改稿带来明显流量变化、评论区出现新模式),会建议追加新 pattern。
### Pattern 11(示例):评论区互动钩子(来自参考博主"哈哈长度")
**现象**:视频末尾的「在评论区告诉我 X」prompt,能:
1. **直接激发 MSMemetic Shareability**——显式调用观众生成内容
2. **降低评论门槛**——观众有具体回答框架
3. **生成可见的群体行为**——评论区集体表演本身就是次级传播载体
**触发条件**:只有当稿子里有**可挪用的具体维度**时才用——观众必须知道往评论区填什么。
---
### Pattern 12(示例):开头软化(沉重议题专用)
**现象**:家庭议题、童年创伤、亲密关系等**沉重议题**,直接断言会触发观众"被冒犯"反应。加"我想"做缓冲:
| 议题类型 | 开头应该 | 情感词应该 |
|---|---|---|
| 家庭 / 创伤 / 性别 | 加"我想"软化 | 柔化("破防"→"感触深"|
| 娱乐 / 科技 / 学术圈梗 | 直接断言 | 适度强 |
---
## 维护建议
- **保持 < 500 行**——每次复盘后追加新 pattern,但也定期删除被推翻的旧 pattern(与 rubric_notes.md 同 lifecycle 协议——见 [observation-lifecycle.md](../cheat-on-content/shared-references/observation-lifecycle.md)
- **第 5 次复盘后**:把上面所有"示例"标的 pattern **完整改写**为你账号的实测——示例只是给你看格式,不是真理
- **新 pattern 必须有 ≥1 样本支持**——单点观察先放到底部"待验证"段,≥2 才升正式 pattern
- 每个 pattern **必须可追溯到具体视频** + 数据(不写"开头钩子很重要",写"参考博主停止期待用 IS 戏仿开场,T+7d 71w"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# cheat-on-content / meta-logging hook
#
# Passive event recorder. Writes one JSON line per event to
# .cheat-cache/usage.jsonl in the project root. Never blocks (async fire-and-forget).
#
# Used by /cheat-status to compute:
# - "distance since last bump" (count of cheat-predict invocations after last_bump_at)
# - skill invocation frequency
# - tool failure patterns
#
# Usage: log-event.sh <event_type>
# <event_type> ∈ {tool_use, user_prompt, session_start, session_end}
#
# Reads from stdin: Claude Code's hook payload JSON
# Output: appends one line to .cheat-cache/usage.jsonl
set -uo pipefail
event_type="${1:-unknown}"
cache_dir="${CLAUDE_PROJECT_DIR:-.}/.cheat-cache"
log_file="${cache_dir}/usage.jsonl"
mkdir -p "$cache_dir" 2>/dev/null || exit 0 # never block on permission errors
# Read hook payload
input=$(cat 2>/dev/null || echo "{}")
# Build a compact event record. Best-effort jq parse — if it fails we still log a minimal record.
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
case "$event_type" in
tool_use|user_prompt|session_start|session_end) ;;
*) event_type="unknown" ;;
esac
if command -v jq >/dev/null 2>&1; then
# Extract a few standard fields if present
event_json=$(printf '%s' "$input" | jq -c --arg ts "$ts" --arg type "$event_type" '
{
ts: $ts,
event: $type,
tool: (.tool_name // null),
file: (.tool_input.file_path // null),
success: (.tool_response.success // null),
prompt_present: ((.user_prompt // null) != null),
prompt_chars: ((.user_prompt // "" | tostring) | length)
}
' 2>/dev/null || echo "")
if [[ -z "$event_json" ]]; then
event_json=$(printf '{"ts":"%s","event":"%s"}' "$ts" "$event_type")
fi
else
# No jq — minimal record
event_json=$(printf '{"ts":"%s","event":"%s"}' "$ts" "$event_type")
fi
# Append (locking is platform-specific; for typical single-user setups append is atomic enough on macOS)
printf '%s\n' "$event_json" >> "$log_file" 2>/dev/null || true
exit 0
+53
View File
@@ -0,0 +1,53 @@
{
"_comment": "cheat-on-content meta-logging hook. Passively records local event metadata to .cheat-cache/usage.jsonl. It stores event type, tool/file metadata, and prompt presence/length only — not raw prompt excerpts. All hooks are async fire-and-forget — never blocks. Used by /cheat-status to compute things like 'distance since last bump'.",
"hooks": {
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PROJECT_DIR}/.cheat-hooks/log-event.sh tool_use &",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PROJECT_DIR}/.cheat-hooks/log-event.sh user_prompt &",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PROJECT_DIR}/.cheat-hooks/log-event.sh session_start &",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PROJECT_DIR}/.cheat-hooks/log-event.sh session_end &",
"timeout": 5
}
]
}
]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"_comment": "cheat-on-content prediction immutability hook. Blocks edits to '## 预测' / '## Prediction' sections in predictions/*.md files. Merge this into your project's .claude/settings.json on /cheat-init.",
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PROJECT_DIR}/.cheat-hooks/prediction-immutability.sh",
"timeout": 3,
"async": false
}
]
}
]
}
}
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
#
# cheat-on-content / prediction-immutability hook
#
# Wires PreToolUse(Edit|Write) → blocks any edit that touches the
# '## 预测' / '## Prediction' section of a file under predictions/.
#
# Allows:
# - Writing brand-new prediction files
# - Editing the file's metadata header (above first ##)
# - Appending to the '## 复盘' / '## Retrospective' section
# - Touching files outside predictions/
#
# Blocks:
# - Any change to lines between '## 预测' (or '## Prediction') and the next H2
#
# Bypass (rare, for true formatting-only fixes):
# CHEAT_BYPASS_IMMUTABILITY=1 — single-shot bypass; logs a warning to stderr
#
# Requirements: bash 3+, jq, diff. Mac default install has all of these.
#
# Exit codes:
# 0 = allow tool call to proceed
# 1 = block tool call (Claude Code will surface stderr to the model)
set -uo pipefail
# Single-shot bypass — opt-in, logs prominently
if [[ "${CHEAT_BYPASS_IMMUTABILITY:-0}" == "1" ]]; then
echo "[cheat-on-content] ⚠️ IMMUTABILITY BYPASS active (CHEAT_BYPASS_IMMUTABILITY=1)" >&2
echo "[cheat-on-content] ⚠️ This should only be used for pure markdown-formatting fixes." >&2
echo "[cheat-on-content] ⚠️ Bypass will be visible in git history." >&2
exit 0
fi
# Read tool call payload from stdin (Claude Code passes JSON)
input=$(cat)
if [[ -z "$input" ]]; then
# No input — let it through (defensive default; nothing to check)
exit 0
fi
# Extract tool name and file path
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || echo "")
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || echo "")
# Only intercept Edit and Write
if [[ "$tool_name" != "Edit" && "$tool_name" != "Write" ]]; then
exit 0
fi
# Only intercept files under predictions/
if [[ -z "$file_path" ]]; then
exit 0
fi
case "$file_path" in
*/predictions/*.md|predictions/*.md)
: # match — continue checking
;;
*)
exit 0
;;
esac
# Allow Write if the file does not yet exist (creating new prediction)
if [[ "$tool_name" == "Write" && ! -f "$file_path" ]]; then
exit 0
fi
# For Edit — extract the old_string and new_string and check whether either touches
# the prediction section.
#
# Strategy: compute the byte range of the '## 预测' (or '## Prediction') section
# in the file BEFORE the edit, then check whether the old_string lies inside that
# range. If yes — block.
if [[ "$tool_name" == "Edit" ]]; then
old_string=$(printf '%s' "$input" | jq -r '.tool_input.old_string // empty' 2>/dev/null || echo "")
if [[ -z "$old_string" ]]; then
exit 0
fi
# Find prediction section bounds. Match '## 预测' / '## Prediction' / '## 预测 v1'
# / '## 预测 v2' / etc. — all version-suffixed prediction headings count as prediction
# sections and are locked together.
#
# Section ends at the first NON-prediction '## ' heading (typically '## 复盘').
prediction_section=$(awk '
/^## / {
if ($0 ~ /^## (预测|Prediction)([^a-zA-Z]|$)/) {
in_pred=1; print; next
} else if (in_pred) {
exit
}
}
in_pred { print }
' "$file_path" 2>/dev/null || echo "")
if [[ -z "$prediction_section" ]]; then
# File has no prediction section — let the edit through.
# (Could be a non-conforming prediction file or an edge case.)
exit 0
fi
# Check whether old_string appears inside the prediction section.
# We use grep -F (literal) on a temporary file because old_string may contain regex chars.
pred_tmp=$(mktemp)
trap "rm -f '$pred_tmp'" EXIT
printf '%s' "$prediction_section" > "$pred_tmp"
if grep -qF -- "$old_string" "$pred_tmp" 2>/dev/null; then
cat >&2 <<EOF
[cheat-on-content] 🚫 BLOCKED: edit targets the '## 预测' / '## Prediction' section of:
$file_path
This violates principle #1 of cheat-on-content: predictions are immutable.
Once written, the prediction section can never be modified — only the
'## 复盘' / '## Retrospective' section can be appended to.
What to do instead:
• If you want to redo the prediction with new info, create a NEW file:
${file_path%.md}_redo.md
The original must be preserved.
• If you noticed a factual mistake AFTER seeing data, document it in the
'## 复盘' section: "Correction: original probability X% should have been Y%".
• If this is a pure markdown-formatting fix (no semantic change), you can
bypass once with: CHEAT_BYPASS_IMMUTABILITY=1 (logs to stderr, visible in git).
See: shared-references/blind-prediction-protocol.md
EOF
exit 1
fi
exit 0
fi
# Write tool on an existing file — that's a full overwrite, definitely touches prediction section.
if [[ "$tool_name" == "Write" && -f "$file_path" ]]; then
cat >&2 <<EOF
[cheat-on-content] 🚫 BLOCKED: Write would overwrite an existing prediction file:
$file_path
Use Edit on the '## 复盘' section to append retrospective content.
Use a new '_redo.md' file path to create a redo prediction.
The original prediction file must be preserved verbatim.
See: shared-references/blind-prediction-protocol.md
EOF
exit 1
fi
exit 0
+18
View File
@@ -0,0 +1,18 @@
{
"_comment": "cheat-on-content SessionStart hook. Auto-renders 4-6 line status report at every new conversation. Synchronous so the report appears in Claude's context BEFORE first reply. Silently exits if not in a cheat-on-content project (no .cheat-state.json found).",
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PROJECT_DIR}/.cheat-hooks/session-start.sh",
"timeout": 5,
"async": false
}
]
}
]
}
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
#
# cheat-on-content SessionStart hook
#
# Renders a 4-6 line status report at the start of every Claude Code session.
# Output is added to Claude's system context — Claude sees it before first reply.
#
# Silently exits if:
# - Not in a cheat-on-content project (no .cheat-state.json)
# - jq not available (status is markdown-readable; Claude can read state.json directly)
#
# Format:
# 📦 Buffer: N (color)
# ⏰ 待复盘: N
# 🎯 候选 top 3: ...
# 📅 上次抓热点: N 天前
# ⚠️ 待办: ...
set -uo pipefail
# Portable ISO-8601 timestamp → epoch converter (works on both GNU/Linux and BSD/macOS)
parse_iso_epoch() {
local input="${1%%+*}" # strip timezone suffix like +08:00
input="${input%%Z}" # strip Z suffix
date -d "$input" "+%s" 2>/dev/null \
|| date -j -f "%Y-%m-%dT%H:%M:%S" "$input" "+%s" 2>/dev/null \
|| echo 0
}
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
STATE_FILE="$PROJECT_DIR/.cheat-state.json"
# Silently skip if not a cheat-on-content project
if [[ ! -f "$STATE_FILE" ]]; then
exit 0
fi
# Skip if jq missing (Claude can still read state.json himself in conversation)
if ! command -v jq >/dev/null 2>&1; then
cat <<'EOF'
[cheat-on-content] SessionStart: jq not installed — skipping auto status report.
Claude can still read .cheat-state.json directly. Say "状态" for full status.
EOF
exit 0
fi
now_epoch=$(date +%s)
today_iso=$(date +%Y-%m-%d)
# --- Read state ---
state=$(cat "$STATE_FILE")
schema_version=$(echo "$state" | jq -r '.schema_version // "unknown"')
rubric_version=$(echo "$state" | jq -r '.rubric_version // "v0"')
calibration_samples=$(echo "$state" | jq -r '.calibration_samples // 0')
target_cadence=$(echo "$state" | jq -r '.target_publish_cadence_days // null')
buffer_count=$(echo "$state" | jq -r '.shoots // [] | length')
pending_retros_count=$(echo "$state" | jq -r '.pending_retros // [] | length')
last_trends_at=$(echo "$state" | jq -r '.last_trends_run_at // ""')
last_published_at=$(echo "$state" | jq -r '.last_published_at // ""')
hooks_installed=$(echo "$state" | jq -r '.hooks_installed // false')
form_severe_mismatch=$(echo "$state" | jq -r '.rubric_form_severe_mismatch // false')
last_prediction_self_scored=$(echo "$state" | jq -r '.last_prediction_self_scored // false')
last_self_scored_at=$(echo "$state" | jq -r '.last_self_scored_at // ""')
# --- Detect schema mismatch (read LATEST_SCHEMA from migrations/registry.md if reachable) ---
# Strategy: hardcode current LATEST_SCHEMA here (bumped by maintainer alongside cheat-init).
# If state.schema_version != LATEST_SCHEMA → suggest migrate (non-blocking).
LATEST_SCHEMA="1.4"
schema_mismatch=""
if [[ "$schema_version" != "$LATEST_SCHEMA" && "$schema_version" != "unknown" ]]; then
schema_mismatch="⚠️ schema 版本不一致:state=${schema_version}, skill 期望=${LATEST_SCHEMA}。建议跑 /cheat-migrate(非阻塞,部分新功能可能在迁移前异常)。"
elif [[ "$schema_version" == "unknown" ]]; then
schema_mismatch="⚠️ state.schema_version 字段缺失或损坏。建议跑 /cheat-status 检查文件,或备份后重 init。"
fi
# --- Detect blind-skip contamination (cheat-predict --skip-blind 或 Phase 2.5 选 b 触发) ---
self_scored_warning=""
if [[ "$last_prediction_self_scored" == "true" && -n "$last_self_scored_at" ]]; then
# Parse timestamp; tolerate +08:00 or Z suffix
self_scored_epoch=$(parse_iso_epoch "$last_self_scored_at")
if [[ $self_scored_epoch -gt 0 ]]; then
days_since=$(( (now_epoch - self_scored_epoch) / 86400 ))
if [[ $days_since -ge 7 ]]; then
self_scored_warning="🚨 距上次 \`--skip-blind\` 自评预测已 ${days_since} 天——校准池累计的 contamination 风险在叠加。下次 /cheat-predict 走 sub-agent 即可清除此提示。"
else
self_scored_warning="⚠️ 上次预测走了 \`--skip-blind\`${days_since} 天前自评,未经 channel B 隔离)。下次 /cheat-predict 走默认即可清除。"
fi
fi
fi
# --- Derive confidence label (single source: state-management.md confidence 表) ---
if [[ $calibration_samples -eq 0 ]]; then
confidence="🔴 极低 (占星级别,纯纪律训练)"
elif [[ $calibration_samples -le 2 ]]; then
confidence="🟠 低 (中枢 ±50%,方向感优于绝对数字)"
elif [[ $calibration_samples -le 5 ]]; then
confidence="🟡 偏低 (中枢 ±40%,可作为参考之一)"
elif [[ $calibration_samples -le 10 ]]; then
confidence="🟢 中 (中枢 ±25%,可参与决策)"
elif [[ $calibration_samples -le 20 ]]; then
confidence="🟢 较高 (中枢 ±15%rubric 形态稳定)"
else
confidence="🔵 高 (中枢 ±10%,可数据驱动)"
fi
# --- Compute buffer color ---
buffer_label=""
buffer_warning=""
if [[ "$target_cadence" == "null" ]] || [[ -z "$target_cadence" ]]; then
# Flexible cadence: no color, just count
buffer_label="📦 Buffer: ${buffer_count} 篇 (灵活节奏,无警戒)"
else
target_cadence_int=${target_cadence%%.*}
buffer_days=$(( buffer_count * target_cadence_int ))
if [[ $buffer_days -lt 1 ]]; then
buffer_label="📦 Buffer: ${buffer_count} 篇 🔴 红 (按 cadence ${target_cadence}d = <1 天预备)"
buffer_warning="🚨 buffer 警戒:下个发布日可能断更。今天必须拍 ≥1 条稳分。"
elif [[ $buffer_days -le 2 ]]; then
buffer_label="📦 Buffer: ${buffer_count} 篇 🟠 橙 (按 cadence ${target_cadence}d = ${buffer_days} 天预备)"
elif [[ $buffer_days -le 5 ]]; then
buffer_label="📦 Buffer: ${buffer_count} 篇 🟢 绿 (按 cadence ${target_cadence}d = ${buffer_days} 天预备)"
else
buffer_label="📦 Buffer: ${buffer_count} 篇 🔵 蓝 (按 cadence ${target_cadence}d = ${buffer_days} 天,积压)"
buffer_warning="📦 buffer 积压:建议暂停拍摄,先发存货 + 复盘。"
fi
fi
# --- Compute pending retros that are actually due ---
retro_window=3 # default RETRO_WINDOW_DAYS, hardcoded fallback (TODO: read from rubric_notes if present)
due_count=0
earliest_due=""
if [[ "$pending_retros_count" -gt 0 ]]; then
# Walk pending_retros, check each prediction file's published_at
while IFS= read -r pred_file; do
pred_path="$PROJECT_DIR/$pred_file"
if [[ -f "$pred_path" ]]; then
pub_iso=$(grep -E '^\*\*Published at\*\*:' "$pred_path" 2>/dev/null | head -1 | sed -E 's/.*: *//')
if [[ -n "$pub_iso" ]]; then
pub_epoch=$(parse_iso_epoch "$pub_iso")
if [[ $pub_epoch -gt 0 ]]; then
age_days=$(( (now_epoch - pub_epoch) / 86400 ))
if [[ $age_days -ge $retro_window ]]; then
due_count=$((due_count + 1))
if [[ -z "$earliest_due" ]] || [[ "$pub_iso" < "$earliest_due" ]]; then
earliest_due="$pub_iso"
fi
fi
fi
fi
fi
done < <(echo "$state" | jq -r '.pending_retros // [] | .[]')
fi
retro_label=""
if [[ $due_count -gt 0 ]]; then
retro_label="⏰ 待复盘: ${due_count} 篇 (最早: ${earliest_due%%T*})"
elif [[ "$pending_retros_count" -gt 0 ]]; then
retro_label="⏰ 待复盘: ${pending_retros_count} 篇 (未到 T+${retro_window}d)"
else
retro_label="⏰ 待复盘: 无"
fi
# --- Top candidates (read first 3 H3 from candidates.md) ---
candidates_file="$PROJECT_DIR/candidates.md"
top_candidates=""
if [[ -f "$candidates_file" ]]; then
# Extract first 3 H3 titles, format compactly
top_candidates=$(grep -E '^### ' "$candidates_file" 2>/dev/null \
| head -3 \
| sed -E 's/^### \[[^]]+\] *//' \
| tr '\n' '/' \
| sed 's:/$::' \
| sed 's:/: / :g')
fi
if [[ -z "$top_candidates" ]]; then
candidates_label="🎯 候选: (空——说 '抓热点' 或 '找选题')"
else
candidates_label="🎯 候选 top 3: ${top_candidates}"
fi
# --- Last trends run ---
trends_label=""
if [[ -n "$last_trends_at" ]]; then
trends_epoch=$(parse_iso_epoch "$last_trends_at")
if [[ $trends_epoch -gt 0 ]]; then
days_ago=$(( (now_epoch - trends_epoch) / 86400 ))
trends_label="📅 上次抓热点: ${days_ago} 天前"
fi
fi
# --- Build the report ---
echo ""
echo "[cheat-on-content / SessionStart 状态报告]"
echo ""
echo "$buffer_label"
echo "$retro_label"
echo "$candidates_label"
[[ -n "$trends_label" ]] && echo "$trends_label"
# Confidence indicator
echo "📈 校准样本: ${calibration_samples} | Confidence: ${confidence}"
# Warnings (high priority)
[[ -n "$buffer_warning" ]] && echo "" && echo "$buffer_warning"
[[ -n "$schema_mismatch" ]] && echo "" && echo "$schema_mismatch"
[[ -n "$self_scored_warning" ]] && echo "" && echo "$self_scored_warning"
if [[ "$form_severe_mismatch" == "true" ]]; then
echo "❌ rubric 与你的内容形态严重不匹配——预测几乎无意义。"
fi
if [[ "$hooks_installed" != "true" ]]; then
echo "⚠️ immutability hook 未装——你的盲预测保护是君子协定,不是物理强制。"
fi
echo ""
echo "(不要主动开始任何动作——等用户决定。说 \"状态\" 看完整看板。)"
echo ""
exit 0
Executable
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env bash
#
# cheat-on-content / install.sh
#
# Symlinks the 15 sub-skills into Claude Code and/or Codex skill directories so
# agents can find them globally. Re-runnable safely (overwrite after confirmation).
#
# After install, in any content project directory: open Claude Code → say "初始化"
# → /cheat-init runs the onboarding.
#
# To uninstall: bash uninstall.sh
#
# Usage:
# bash install.sh # Claude Code install, symlink mode (default)
# bash install.sh --copy # Claude Code install, copy mode
# bash install.sh --codex # Codex install into ~/.codex/skills/
# bash install.sh --all # install for Claude Code and Codex
# bash install.sh --codex --copy # Codex install, copy mode
# bash install.sh --reinstall-hooks <project-dir>
# # rewrite hook scripts in an existing user project's .cheat-hooks/
# # (use after git pull when CHANGELOG mentions hook script changes;
# # does NOT touch .cheat-state.json or any user data)
set -euo pipefail
SUB_SKILLS=(
cheat-init
cheat-learn-from
cheat-seed
cheat-score
cheat-score-blind
cheat-predict
cheat-shoot
cheat-publish
cheat-retro
cheat-persona
cheat-bump
cheat-recommend
cheat-trends
cheat-status
cheat-migrate
)
CLAUDE_SKILLS=("${SUB_SKILLS[@]}")
CODEX_SKILLS=(cheat-on-content "${SUB_SKILLS[@]}")
# Resolve the directory containing THIS script (the source root) — needed early for both modes
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
MODE="symlink"
TARGET_AGENT="claude"
# --- --reinstall-hooks branch: rewrite a user project's hook scripts only ---
if [[ "${1:-}" == "--reinstall-hooks" ]]; then
PROJECT_DIR="${2:-}"
if [[ -z "$PROJECT_DIR" ]]; then
echo "❌ Usage: bash install.sh --reinstall-hooks <path-to-user-project>"
echo " The user project must already have been initialized via /cheat-init."
exit 1
fi
if [[ ! -d "$PROJECT_DIR" ]]; then
echo "❌ Project dir not found: $PROJECT_DIR"
exit 1
fi
if [[ ! -f "$PROJECT_DIR/.cheat-state.json" ]]; then
echo "$PROJECT_DIR is not a cheat-on-content project (no .cheat-state.json)."
echo " Run /cheat-init in that directory first."
exit 1
fi
HOOK_DST="$PROJECT_DIR/.cheat-hooks"
mkdir -p "$HOOK_DST"
echo ""
echo "Reinstalling hook scripts in: $PROJECT_DIR"
echo " source: $SCRIPT_DIR/hooks/"
echo ""
for hook_script in prediction-immutability.sh session-start.sh log-event.sh; do
if [[ -f "$SCRIPT_DIR/hooks/$hook_script" ]]; then
cp "$SCRIPT_DIR/hooks/$hook_script" "$HOOK_DST/$hook_script"
chmod +x "$HOOK_DST/$hook_script"
echo " ✓ updated: .cheat-hooks/$hook_script"
else
echo " ⚠️ missing in source: hooks/$hook_script (skipped)"
fi
done
echo ""
echo "✅ Hook scripts reinstalled."
echo ""
echo "Note: This did NOT touch:"
echo " - .cheat-state.json (your data)"
echo " - .claude/settings.json (hook registration — should still point at .cheat-hooks/)"
echo " - rubric_notes.md / predictions/ / videos/ (your work)"
echo ""
echo "If schema also changed (CHANGELOG marks BREAKING), additionally run /cheat-migrate"
echo "in Claude Code from your project directory."
echo ""
exit 0
fi
for arg in "$@"; do
case "$arg" in
--copy)
MODE="copy"
;;
--claude)
TARGET_AGENT="claude"
;;
--codex)
TARGET_AGENT="codex"
;;
--all)
TARGET_AGENT="all"
;;
--help|-h)
sed -n '1,35p' "$0"
exit 0
;;
*)
echo "❌ Unknown argument: $arg"
echo " Usage: bash install.sh [--copy] [--claude|--codex|--all]"
exit 1
;;
esac
done
# Sanity check: confirm we're in the cheat-on-content root
if [[ ! -f "$SCRIPT_DIR/SKILL.md" ]]; then
echo "❌ Missing: $SCRIPT_DIR/SKILL.md"
echo " Are you running install.sh from the cheat-on-content root?"
exit 1
fi
for s in "${SUB_SKILLS[@]}"; do
if [[ ! -f "$SCRIPT_DIR/skills/$s/SKILL.md" ]]; then
echo "❌ Missing: $SCRIPT_DIR/skills/$s/SKILL.md"
echo " Are you running install.sh from the cheat-on-content root?"
exit 1
fi
done
skill_source() {
local skill="$1"
if [[ "$skill" == "cheat-on-content" ]]; then
echo "$SCRIPT_DIR"
else
echo "$SCRIPT_DIR/skills/$skill"
fi
}
detect_conflicts() {
local target_dir="$1"
shift
local warned=0
for s in "$@"; do
local src
src=$(skill_source "$s")
local target="$target_dir/$s"
if [[ -e "$target" || -L "$target" ]]; then
if [[ -L "$target" ]]; then
local existing
existing=$(readlink "$target")
if [[ "$existing" != "$src" ]]; then
echo "⚠️ $target already symlinked to: $existing"
warned=1
fi
else
echo "⚠️ $target exists (not a symlink) — will be overwritten"
warned=1
fi
fi
done
return "$warned"
}
install_skills() {
local label="$1"
local target_dir="$2"
shift 2
mkdir -p "$target_dir"
echo ""
echo "Installing cheat-on-content for $label (mode: $MODE)"
echo " source: $SCRIPT_DIR"
echo " target: $target_dir/"
echo ""
for s in "$@"; do
local src
src=$(skill_source "$s")
local dst="$target_dir/$s"
if [[ -e "$dst" || -L "$dst" ]]; then
rm -rf "$dst"
fi
if [[ "$MODE" == "symlink" ]]; then
ln -s "$src" "$dst"
echo " ✓ symlinked: $s"
else
cp -R "$src" "$dst"
if [[ "$s" == "cheat-on-content" ]]; then
rm -rf "$dst/.git"
fi
echo " ✓ copied: $s"
fi
done
}
WARNED=0
if [[ "$TARGET_AGENT" == "claude" || "$TARGET_AGENT" == "all" ]]; then
detect_conflicts "$HOME/.claude/skills" "${CLAUDE_SKILLS[@]}" || WARNED=1
fi
if [[ "$TARGET_AGENT" == "codex" || "$TARGET_AGENT" == "all" ]]; then
detect_conflicts "$HOME/.codex/skills" "${CODEX_SKILLS[@]}" || WARNED=1
fi
if [[ $WARNED -eq 1 ]]; then
echo ""
read -p "Continue and overwrite? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
fi
if [[ "$TARGET_AGENT" == "claude" || "$TARGET_AGENT" == "all" ]]; then
install_skills "Claude Code" "$HOME/.claude/skills" "${CLAUDE_SKILLS[@]}"
fi
if [[ "$TARGET_AGENT" == "codex" || "$TARGET_AGENT" == "all" ]]; then
install_skills "Codex" "$HOME/.codex/skills" "${CODEX_SKILLS[@]}"
fi
echo ""
echo "✅ Install complete!"
echo ""
echo "Next steps:"
echo " 1. cd into your content project (or create one):"
echo " mkdir ~/my-channel && cd ~/my-channel"
echo ""
echo " 2. Open Claude Code or Codex in that directory"
echo ""
echo " 3. In the chat, say:"
echo " 初始化"
echo " (or: 初始化 cheat-on-content)"
echo ""
if [[ "$TARGET_AGENT" == "claude" || "$TARGET_AGENT" == "all" ]]; then
echo "Verify Claude install: ls -la ~/.claude/skills/ | grep cheat"
fi
if [[ "$TARGET_AGENT" == "codex" || "$TARGET_AGENT" == "all" ]]; then
echo "Verify Codex install: ls -la ~/.codex/skills/ | grep cheat"
echo "Note: restart Codex if the new skills do not appear in the current session."
fi
echo ""
if [[ "$MODE" == "symlink" ]]; then
echo "️ Mode: symlink — edits to source SKILL.md files take effect immediately."
if [[ "$TARGET_AGENT" == "codex" ]]; then
echo " To switch to frozen copy: bash install.sh --codex --copy"
elif [[ "$TARGET_AGENT" == "all" ]]; then
echo " To switch to frozen copy: bash install.sh --all --copy"
else
echo " To switch to frozen copy: bash install.sh --copy"
fi
else
echo "️ Mode: copy — frozen at install time. Re-run install.sh to update."
fi
echo ""
+99
View File
@@ -0,0 +1,99 @@
# Migration: schema_version 1.0 → 1.1
> **Type**: MINOR(兼容;老 state 用默认值读不会崩,但跑迁移让字段语义对齐)
>
> **Status**: 这是 v1 第一份正式 migration——主要作为模板和示例。绝大多数早期用户直接从 init 出来就是 1.1,无需迁移。
---
## WHAT changed
### 删除字段
| 字段 | 旧值 | 删除原因 |
|---|---|---|
| `mode` | "cold-start" / "calibration" 二元 | 用 `calibration_samples` 整数替代——硬切换是设计者猜测,连续整数更准确 |
| `prediction_complexity` | "cold-start-simple" / "complete" | 所有预测都用统一 7 组件结构,confidence 派生自 calibration_samples |
| `bucket_scheme` | "ratio" / "absolute" / "absolute_with_ratio" / "percentile" | bucket 边界由单一算法自动派生(有 `baseline_plays` → 倍数;无 → 平台默认) |
### 新增字段
| 字段 | 默认值 | 写入者 |
|---|---|---|
| `typical_duration_seconds` | 240 | cheat-init Q1.5 派生 |
| `target_publish_cadence_days` | null(灵活节奏) | cheat-init Q1.6 派生 |
| `rubric_form_mismatch` | false | cheat-initcontent_form ≠ opinion-video 时 true |
| `benchmark_status` | "none" | cheat-init Phase 2.5 |
| `benchmark_name` | null | cheat-learn-from |
| `benchmark_sample_count` | 0 | cheat-learn-from |
| `baseline_plays` | null | cheat-init / cheat-retro |
| `enabled_perf_adapters` | `[]` | cheat-init |
| `last_published_file` | null | cheat-publish |
| `last_retro_at` | null | cheat-retro |
| `pending_retros` | `[]` | cheat-publish / cheat-retro |
| `shoots` | `[]` | cheat-shoot / cheat-publish |
---
## WHY
v1.0 的 `mode` 是离散二元,强迫"前 5 篇 cold-start vs 后续 calibration"二选一。实际频道是**连续光谱**——第 4 篇和第 6 篇没有质变。用整数 `calibration_samples` 配一张派生表(详见 [shared-references/state-management.md](../shared-references/state-management.md) 的 confidence 派生表),既保留信号又消除假断点。
类似地 `bucket_scheme` 把"bucket 怎么分"硬塞给用户决定,但用户根本没数据决定——交给算法从 baseline_plays 派生才合理。
---
## HOW (Claude steps for /cheat-migrate)
按顺序:
1.`.cheat-state.json` → 解析 JSON
2. 校验 `schema_version == "1.0"`,否则报错"当前 state 不是 1.0,无需此迁移"
3. 派生新字段值:
- `typical_duration_seconds`: 询问用户"你的视频典型时长多少秒?"(默认 240)
- `target_publish_cadence_days`: 询问"目标发布频率(1=日更/2=隔日/7=周更/null=灵活)"(默认 null
- `rubric_form_mismatch`: 检查 content_form 是否 ≠ "opinion-video"v1.0 字段保留)→ 是则 true
- `benchmark_status`: 默认 "none"
- `benchmark_name`: null
- `benchmark_sample_count`: 0
- `baseline_plays`: 扫 `predictions/` 找含实绩的 reconstructed predictions → 取播放数中位数;找不到→ null
- `enabled_perf_adapters`: `[]`
- `last_published_file`: null(不能从 1.0 字段反推)
- `last_retro_at`: null(同上)
- `pending_retros`: 扫 `predictions/` 找有 `published_at` 但无 `## 复盘` 的 → 列表
- `shoots`: 扫 `videos/` 找有 script.md 但对应 prediction 无 `published_at` 的 → 构造 shoot 条目
4. 删除字段:`mode`, `prediction_complexity`, `bucket_scheme`
5.`schema_version = "1.1"`
6. **原子写**:写到 `.cheat-state.json.tmp` → rename
7. 报告:"✅ 迁移到 1.1 完成。新增 N 字段,删除 3 字段。"
---
## Manual fallback
不想跑 `/cheat-migrate` 的用户可以手改 `.cheat-state.json`
```diff
{
"schema_version": "1.0",
+ "schema_version": "1.1",
- "mode": "cold-start",
- "prediction_complexity": "cold-start-simple",
- "bucket_scheme": "ratio",
+ "typical_duration_seconds": 240,
+ "target_publish_cadence_days": null,
+ "rubric_form_mismatch": false,
+ "benchmark_status": "none",
+ "benchmark_name": null,
+ "benchmark_sample_count": 0,
+ "baseline_plays": null,
+ "enabled_perf_adapters": [],
+ "last_published_file": null,
+ "last_retro_at": null,
+ "pending_retros": [],
+ "shoots": [],
...
}
```
> 手改后建议跑 `/cheat-status` 验证 state 文件能被所有 skill 正常读。
+66
View File
@@ -0,0 +1,66 @@
# Migration: schema_version 1.1 → 1.2
> **Type**: MINOR(兼容;老 state 用 `state.get(field, default)` 兜底不会崩,跑迁移让 shoots[] 字段对齐 schema 文档)
---
## WHAT changed
### `shoots[]` 项新增 5 个字段
| 字段 | 类型 | 默认 | 含义 |
|---|---|---|---|
| `scripts_path` | string | 从 `prediction_file` 路径派生 | pre-shoot 草稿路径 |
| `script_consistency` | enum | `"consistent"` | `"consistent"` / `"modified"` / `"lost"` — 拍时是否改稿 |
| `script_diff_pct` | int / null | `null` | 改动行占比 0-100;老 shoot 没算过则 null |
| `v2_prediction_written` | bool | `false` | prediction 文件是否已含 `## 预测 v2` 段 |
| `script_hash_at_shoot` | string / null | `null` | `videos/<id>/script.md` 的 sha256 前 12 位 |
### 新概念(不在 state 文件,在 prediction 文件 header
`Prediction Basis` 字段:`pre_shoot`v1 默认)/ `post_shoot_pre_publish`v2
---
## WHY
v1.2 引入"拍后改稿触发 v2 预测重判"——cheat-shoot 检测稿子 diff ≥30% 时调用 `/cheat-predict — mode: v2`,在原 prediction 文件 append `## 预测 v2` 段。
为了让 cheat-retro 知道"哪条预测进校准计算",shoots[] 项必须记录是否有 v2,以及 diff 强度(用于 rubric 升级时分析"用户改稿模式")。
---
## HOW (Claude steps for /cheat-migrate)
按顺序:
1.`.cheat-state.json` → 解析 JSON
2. 校验 `schema_version == "1.1"`,否则报错"当前 state 不是 1.1,无需此迁移"
3.`state["shoots"]` 列表里的每一项:
-`scripts_path` 缺失 → 从 `prediction_file` 派生(把 `predictions/` 替换为 `scripts/`
-`script_consistency` 缺失 → 默认 `"consistent"`
-`script_diff_pct` 缺失 → 默认 `null`
-`v2_prediction_written` 缺失 → **扫对应 prediction 文件**
- `grep -q '^## 预测 v2' <prediction_file>` → true 则写 `true`,否则 `false`
-`script_hash_at_shoot` 缺失 → 默认 `null`
4.`schema_version = "1.2"`
5. **原子写**:写到 `.cheat-state.json.tmp` → rename
6. 报告:"✅ 迁移到 1.2 完成。处理了 N 个 shoots[] 项。"
---
## Manual fallback
不想跑 `/cheat-migrate` 的用户可以**完全不动 state**——所有 skills 都用 `state.get(field, default)` 兜底,新字段会自动用默认值。但 `schema_version` 字段会一直显示 `"1.1"`SessionStart hook 会持续提示 mismatch warning。
如果想消除 warning 又不想跑 skill,手改:
```diff
{
- "schema_version": "1.1",
+ "schema_version": "1.2",
...
}
```
shoots[] 里的字段会在下次 cheat-shoot / cheat-publish 触发时按需补全。
+80
View File
@@ -0,0 +1,80 @@
# Migration: schema_version 1.2 → 1.3
> **Type**: MINOR(兼容;老 state 用 `state.get(field, default)` 兜底不会崩,跑迁移让字段对齐 schema 文档)
---
## WHAT changed
### state file 新增 2 个字段
| 字段 | 类型 | 默认 | 含义 |
|---|---|---|---|
| `last_prediction_self_scored` | bool | `false` | 上次 `/cheat-predict` 是否走了 `--skip-blind` 或 Phase 2.5 选 b(信主 Claude 自估而非 sub-agent 盲打分) |
| `last_self_scored_at` | string / null | `null` | 上一条 `last_prediction_self_scored=true` 触发的 ISO 8601 时间戳;下次走 sub-agent 时一起清回 null |
### 新概念(不在 state,在 prediction 文件 header
引入 **`BlindScored By`** + **`BlindScore Disagreement`** 两个 header 字段——见 [shared-references/prediction-anatomy.md](../shared-references/prediction-anatomy.md) 组件 1。
老的 prediction 文件没有这两个字段——**不强制回填**,cheat-retro 读取时 graceful default。
---
## WHY
v1.3 引入 [skills/cheat-score-blind](../skills/cheat-score-blind/SKILL.md) sub-agent 作为 channel B——通过 Task tool 隔离打分动作,让 sub-agent 只看 script + rubric_notes.md,避免主对话被实绩/复盘/用户态度污染。
`/cheat-predict` Phase 2 默认走 sub-agent`--skip-blind` 是 escape hatch。一旦使用 escape hatch,必须留下追踪轨迹——这就是 `last_prediction_self_scored` 存在的理由。
`/cheat-bump` Phase 2 **强制**走 sub-agent,不接受 fallback;所以 bump 不会触发 `last_prediction_self_scored=true`
---
## HOW (Claude steps for /cheat-migrate)
按顺序:
1.`.cheat-state.json` → 解析 JSON
2. 校验 `schema_version == "1.2"`,否则报错"当前 state 不是 1.2,无需此迁移"
3. 检查 `last_prediction_self_scored` 字段:
- 不存在 → 默认 `false`
- 已存在(罕见,用户手改过)→ 保留原值
4. 检查 `last_self_scored_at` 字段:
- 不存在 → 默认 `null`
- 已存在 → 保留原值
5.`schema_version = "1.3"`
6. **原子写**:写到 `.cheat-state.json.tmp` → rename
7. 报告:"✅ 迁移到 1.3 完成。新增 2 字段,无字段被删除。"
**不需要回填老 prediction 文件**——`BlindScored By` 字段缺失时按 `unknown` 处理(cheat-retro 在 reading 时 graceful default)。如果用户想标记"这些老 predictions 都是 v1.2 时代的 inline 打分",可手动在文件头加 `BlindScored By: legacy-inline`——但**不在 migration 自动执行**,避免改动现有 prediction 文件触发 immutability hook。
---
## Manual fallback
不想跑 `/cheat-migrate` 的用户可以**完全不动 state**——所有 skills 都用 `state.get(field, default)` 兜底,新字段自动用默认值。但 `schema_version` 字段会一直显示 `"1.2"`SessionStart hook 会持续提示 mismatch warning。
如果想消除 warning 又不想跑 skill,手改:
```diff
{
- "schema_version": "1.2",
+ "schema_version": "1.3",
+ "last_prediction_self_scored": false,
+ "last_self_scored_at": null,
...
}
```
下次 `/cheat-predict` 走 sub-agent 时这两个字段会被自然 touch 到(写为 false / null),手改其实可选。
---
## 验收
迁移完成后:
- `jq '.schema_version' .cheat-state.json``"1.3"`
- `jq '.last_prediction_self_scored' .cheat-state.json``false`(或保留的旧值)
- `jq '.last_self_scored_at' .cheat-state.json``null`(同上)
-`bash hooks/session-start.sh` → 不再有 schema mismatch warning
+150
View File
@@ -0,0 +1,150 @@
# Migration: schema_version 1.3 → 1.4
> **Type**: MINOR **but BREAKING for blind channel integrity**——state 字段不变,但 `rubric_notes.md` 必须拆开。**老用户跑 migrate 之前 blind sub-agent 仍泄漏实绩**。
---
## WHAT changed
### 文件层(**核心**
| 之前 | 之后 |
|---|---|
| `rubric_notes.md` 顶部累积所有 bump Memo(含视频名 + 实绩 + 派生证据) | `rubric_notes.md` 只含通用语言(公式 / 维度定义 / bucket 边界 / 指针),**绝不**含实绩 |
| —— | `rubric-memo.md`**新**)累积所有 bump Memo 全文(含视频名 + 实绩 + 派生证据) |
### state 字段
无新增 / 删除 / 改名。仅 `schema_version` bump 标识本次"文件层迁移"必跑。
### channel B hard refusal list
`cheat-score-blind/SKILL.md``rubric-memo.md` 路径 + refusal_code `blocked_rubric_memo`
---
## WHY
PR #11 引入 blind-scoring sub-agentchannel B)时承诺它只能读 `scripts/<id>.md` + `rubric_notes.md`。但 cheat-bump Phase 5 落地时把升级 Memo(含证据数据表 + 派生证据 + 真实视频名 + 实绩)写进 rubric_notes.md ——sub-agent 通过白名单读到了本不该看的实绩数据,盲打分变成"看过实绩的事后合理化"。
实测复现(PR #11 测试):
```
contamination_note: rubric_notes.md v0→v1 bump memo 含本 script 同名条目「它让我发的 38w」实绩
contamination_note: rubric_notes.md 派生证据段含本 script 名+实绩 13.7wCC 锚点被预先校准
→ refusal: "non_blind_warning"
→ all dimensions confidence: medium
```
修复:拆 file。`rubric_notes.md` 严格只放通用规则(blind 白名单),bump 历史档案沉淀到 `rubric-memo.md`blind 硬禁读)。详见 [shared-references/observation-lifecycle.md](../shared-references/observation-lifecycle.md) "Blind channel leak guard" 段。
---
## HOW (Claude steps for /cheat-migrate)
按顺序执行。**不可幂等的步骤要先检查是否已跑过**——本迁移涉及内容文件改写,重跑可能产生重复内容。
### Step 1:前置检查
1.`.cheat-state.json` → 校验 `schema_version == "1.3"`,否则报错"当前 state 不是 1.3,无需此迁移"
2. 检查 `rubric_notes.md` 存在;不存在 → 报错"找不到 rubric_notes.md。是不是项目还没 init"
3. 检查 `rubric-memo.md` **是否已存在**
- 已存在 → 询问用户"rubric-memo.md 已有内容,跳过 split 直接 bump schema 到 1.4?回 yes 跳过,回 no 中止迁移"
- 不存在 → 进 Step 2
### Step 2:备份
```bash
cp rubric_notes.md rubric_notes.md.backup-$(date +%s)
```
提示用户:"已备份 `rubric_notes.md` —— 拆分若出错可恢复"。
### Step 3:扫描 + 抽离 `rubric_notes.md` 的违禁段
`rubric_notes.md` 全文。识别下列 pattern
| Pattern | 处理 |
|---|---|
| 整段 `## v.X → v.Y 升级 Memo` 含子段(触发观察 / 证据数据 / 诊断 / 跨模型审核 / 已知局限) | **整段抽离**到 rubric-memo.md,原位**删除** |
| 派生证据段中带视频名 + 数字(如「停止期待」实绩 13.7w) | **整条抽离**到 rubric-memo.md 的"派生证据"子段;原位用通用语言替代("高抽象密度样本 → CC=1 → 低 reach" |
| 校准池重打表 / 排序对照表 | **整表抽离**到 rubric-memo.md 对应 Memo 的"证据数据"子段;原位删除(公式速查表保留——公式速查不含视频名) |
| 跨模型审核引用含具体视频名 + 实绩 | 抽离 |
| 散落的 `\d+w` / `\d+万` 数字(**例外:bucket 边界** 5-30w / 30-100w 等保留) | 用 Claude 判断:是 bucket 边界 → 保留;是实绩引用 → 抽离 |
### Step 4:写 `rubric-memo.md`
如果文件不存在 → 从 `cheat-on-content/templates/rubric-memo.template.md` 复制空骨架。
然后把 Step 3 抽离出来的 Memo 段**按时间顺序**(旧的在上 / 新的在下)append 到文件末尾。每段 Memo 用 [templates/rubric-memo.template.md](../templates/rubric-memo.template.md) 的 6 组件格式包装;缺组件的标 `(unknown from legacy migration)`
### Step 5:清理 `rubric_notes.md`
- 顶部 metadata 段加:
```markdown
**Upgrade memos**: 见 [rubric-memo.md](rubric-memo.md)
```
- Memo 段被 Step 3 抽走的部分留**短指针**"v0→v1 升级 Memo 已迁移到 rubric-memo.md,跑 `cat rubric-memo.md` 查看"
- 派生证据段替换为通用语言
### Step 6:自检 leak guard
对清理后的 `rubric_notes.md` 跑:
```bash
grep -E '\d+\s*[wWmMkK万]|播放|实绩|实际' rubric_notes.md \
| grep -v -E '^\s*\|[^|]*\|[^|]*\|.*[0-9]+\s*[wkM][^a-zA-Z]' \
| grep -v -E '5-30w|30-100w|100-150w|>150w'
```
grep 排除明显的 bucket 边界数字)
- 无命中 → 通过
- 命中 → 报告剩余的"实绩引用" + 让用户手工 review / 进一步抽离 → 继续 Step 7 或中止
### Step 7bump schema + 写回 state
1. 设 `.cheat-state.json` 的 `schema_version = "1.4"`
2. **原子写**
3. 报告:"✅ 迁移到 1.4 完成。`rubric_notes.md` 现仅含通用语言({N} 行),`rubric-memo.md` 含 {M} 段历史 Memo。备份保留:rubric_notes.md.backup-{ts}。"
---
## Manual fallback
不想跑 `/cheat-migrate` 的用户可以**手动拆**
1. 备份:`cp rubric_notes.md rubric_notes.md.backup`
2. 把所有 `## v.X → v.Y 升级 Memo` 段从 `rubric_notes.md` 整体剪切到**新建**的 `rubric-memo.md`(按时间顺序保留)
3. 把派生证据段的视频名 + 实绩剪到 rubric-memo.md,原位用通用语言替代
4. 在 `rubric_notes.md` 顶部加 `**Upgrade memos**: 见 [rubric-memo.md](rubric-memo.md)` 指针
5. 手改 `.cheat-state.json` 的 `schema_version` 为 `"1.4"`
或者**最懒**:删 `rubric_notes.md` 重 init(损失:所有历史观察 + bump memo),跑 `bash install.sh` 后说"初始化"——cheat-init 会重新创建两份空文件。仅适合校准池还很小(< 5 条)的早期用户。
---
## 验收
迁移完成后:
```bash
# state 升到 1.4
jq '.schema_version' .cheat-state.json
# → "1.4"
# rubric_notes.md 无实绩泄漏
grep -E '播放|实绩|实际|\d+w[^a-zA-Z0-9]' rubric_notes.md \
| grep -v -E '5-30w|30-100w|100-150w|>150w' \
| head
# → 应输出空(除 bucket 边界)
# rubric-memo.md 含历史 Memo
ls -la rubric-memo.md
wc -l rubric-memo.md
# → 行数 > 50(取决于历史 bump 次数)
# blind sub-agent 不再标 non_blind_warning
# 跑 /cheat-score scripts/<已发样本>.md
# → JSON.refusal == null, JSON.self_check.any_contamination_signal == false
```
+67
View File
@@ -0,0 +1,67 @@
# Migrations Registry
cheat-on-content 的 schema 版本演进单一来源。`/cheat-migrate` 读这份文件决定要跑哪些迁移。
---
## 当前 schema_version
**`1.4`** —— 由 `cheat-init` Phase 3 写入新 state 文件。
下方 `LATEST_SCHEMA` 标记位被 `cheat-migrate` skill 和 SessionStart hook 引用:
```
LATEST_SCHEMA = "1.4"
```
> 维护者注意:bump 这个值的同时**必须**新增对应迁移文件 + 在下方"版本链"追加一行。
---
## 版本链
按时间顺序,每行表示一个 schema 升级。`/cheat-migrate` 用此表算出从用户当前版本到 LATEST_SCHEMA 需要按顺序跑哪些 step。
| from | to | breaking? | 迁移文件 | 描述 |
|---|---|---|---|---|
| (none) | 1.0 | — | (内置) | v1 首版 schema |
| 1.0 | 1.1 | NO | [1.0-to-1.1.md](1.0-to-1.1.md) | 删 `mode` / `prediction_complexity` / `bucket_scheme` 三个枚举字段;新增 `target_publish_cadence_days` / `baseline_plays` / `benchmark_*` / `shoots` 等 |
| 1.1 | 1.2 | NO | [1.1-to-1.2.md](1.1-to-1.2.md) | `shoots[]` 项扩展 5 字段(`scripts_path` / `script_consistency` / `script_diff_pct` / `v2_prediction_written` / `script_hash_at_shoot`)— 配合"拍后改稿触发 v2 预测重判"工作流 |
| 1.2 | 1.3 | NO | [1.2-to-1.3.md](1.2-to-1.3.md) | 新增 `last_prediction_self_scored: bool` + `last_self_scored_at` 字段——配合 cheat-score-blind sub-agent 引入的 channel B 隔离打分。`true` 表示上次预测走了 `--skip-blind`cheat-status 持续 nag |
| 1.3 | 1.4 | **BREAKING for blind channel** | [1.3-to-1.4.md](1.3-to-1.4.md) | rubric 文件拆分:`rubric_notes.md``rubric_notes.md`blind 白名单,通用语言)+ `rubric-memo.md`(blind 硬禁读,含真实视频名/实绩)。state 字段不变;老用户必须跑 migrate 把现有 rubric_notes.md 拆开。不跑 → blind sub-agent 仍会标 non_blind_warning |
---
## 迁移文件命名约定
- 文件名:`<from>-to-<to>.md`(如 `1.1-to-1.2.md`
- 每份必含 4 段:
1. **WHAT changed** — 字段层 diff(新增 / 删除 / 重命名)
2. **WHY** — 为什么这个改动
3. **HOW (Claude steps)** — Claude 跑 `/cheat-migrate` 时按顺序执行的自然语言步骤
4. **Manual fallback** — 如果用户不想跑 skill,手改 `.cheat-state.json` 的最小指令
---
## 哲学(详见 [shared-references/migration-protocol.md](../shared-references/migration-protocol.md)
- **MINOR bump**(如 1.1 → 1.2):仅新增字段或软化 enum 取值。老 state 用 `state.get(field, default)` 读到默认值,**可以不跑 migrate**——但跑了能让 state 文件变完整
- **MAJOR bump**(如 1.x → 2.0):删字段 / 重命名字段 / 改字段语义。老 state **必须**跑 migrate,否则 skill 会读到不一致的字段
- **不允许跳版**:1.0 用户升到 1.3 必须按顺序跑 1.0→1.1、1.1→1.2、1.2→1.3。每步幂等
- **失败停在原地**:迁移到第 N 步失败 → state.schema_version 仍是 N-1(不是目标版本)。修复后重跑能从断点继续
---
## 给开发者:新增一个迁移
bump schema 时(顺序):
1. 想清楚是 MINOR 还是 MAJOR(参考上面哲学段)
2.`cheat-init/SKILL.md``schema_version` 硬编码为新值
3. 改本文件的 `LATEST_SCHEMA = "X.Y"` 标记位
4. 本文件"版本链"表追加一行
5. 新建 `migrations/<old>-to-<new>.md`4 段都填
6. CHANGELOG.md 标 `BREAKING`major)或 `MINOR`,加迁移文件链接
7. 跑一遍:在 fixture 老 state 上调 `/cheat-migrate`,验证升级到新版且 state 字段齐全
如果懒得写 4 段的标准 migration 文件,把 schema bump **推迟**到下一次有规模改动时一起做——不要先 bump schema 再补文档。
@@ -0,0 +1,137 @@
# Blind Prediction Protocol(盲预测协议)
被这些子 skill 引用:`cheat-predict``cheat-retro`、主 SKILL.md。
这是项目原则 #1 的完整规范。任何子 skill 在写预测前都必须执行本协议。
---
## 核心定义
**盲预测**:在预测者(人或模型)看到任何关于该作品发布后真实表现数据**之前**完成的预测。
预测一旦写入 `predictions/*.md``## 预测` 段,该段即为 **immutable**——只能在文件末尾追加 `## 复盘` 段,不能修改预测段任何字符。
---
## "见过数据"的边界(关键,常被违反)
下列任一条件成立 → 已不再 blind,**禁止写预测**:
| 信息 | 是否破坏 blind | 例外 |
|---|---|---|
| 该作品任何平台的播放数 / 阅读数 | ✗ 破坏 | 无 |
| 该作品的点赞 / 评论 / 转发数 | ✗ 破坏 | 无 |
| 该作品的具体评论内容 | ✗ 破坏 | 无 |
| 该作品的算法推荐位 / 热门榜位置 | ✗ 破坏 | 无 |
| 该作品发布后的截图 / 后台数据 | ✗ 破坏 | 无 |
| **同期发布的其他人**作品的表现 | ○ 不破坏 | — |
| **历史上类似主题**作品的表现 | ○ 不破坏(这正是锚点对比要做的) | — |
| 该作品**发布前**的稿子内容 | ○ 不破坏 | 这是预测的输入 |
| 用户口述的"我感觉这条还行" | △ 谨慎 | 用户的主观感觉不算"数据",但要在预测里标注用户偏见 |
**判断捷径**:只要这条信息**只能在作品发布后才能获得**,就算"数据"。
---
## 预测者必须主动声明的情况
子 skill 在启动 `cheat-predict` 前,必须自检并向用户**主动声明**:
1. **作品已发布超过 RETRO_WINDOW_DAYS 天**(默认 3 天)→ 必须拒绝写"预测",改记为 `**Reconstructed retrospective**`,明确标注非预测
2. **作品已发布但 < RETRO_WINDOW_DAYS 天,用户尚未透露任何数据**→ 允许 blind 预测,但在文件头部标记 `published_before_prediction: true` + `blind_status: confirmed_no_data_seen`
3. **用户在对话里已粘贴了任何后续数据**→ 同 #1 处理,记为 reconstructed
`BLIND_CHECK=strict`(默认):上述任何破坏条件命中,**拒绝执行**。
`BLIND_CHECK=lenient`:仅警告 + 强制标注,允许继续——只用于离线测试或学术演练,**不推荐用于真实校准**。
---
## Immutable 的工程边界
`## 预测` 段的不可修改是**用户体验承诺**,由 hook 层强制:
- `hooks/prediction-immutability.sh` 在 PreToolUse(Edit|Write) 上检查 `predictions/` 下文件
- 命中 `## 预测` 与下一个二级标题之间任何 diff → exit 1 阻塞
- `## 复盘` 段的追加 → 放行
**禁止的"绕开"模式**(子 skill 必须拒绝):
- "把预测段重写得更准一点" → 拒绝。如有正当理由重做,创建新文件 `<原文件名>_redo.md`,原文件保留
- "我的概率分布写错了 0.5%,让我改一下" → 拒绝。在复盘段追加 `修正:原概率分布 X% 应为 Y%,于 <date> 发现笔误`
- "我前面没考虑 SR=4,重打一下分" → 拒绝。同上路径
唯一允许编辑预测段的场景:**纯 markdown 排版错误**(标题层级错误、列表 bullet 格式错),且用户明确说明这是格式修复。这种情况 hook 仍会阻塞,需要用户显式 bypass(手动设置环境变量 `CHEAT_BYPASS_IMMUTABILITY=1` 单次)——bypass 应在 git history 留痕。
---
## 文件名约定(**三处一致**
一个内容三处文件,**用同一组 `<date>_<id>_<short>` 命名**
```
scripts/<date>_<id>_<short>.md ← pre-shoot 草稿(cheat-seed 写或用户写)
predictions/<date>_<id>_<short>.md ← immutable 预测(cheat-predict 写)
videos/<date>_<id>_<short>/ ← 拍后才建(cheat-shoot 创建)
├── script.md ← 用户提供的最终拍摄稿
└── report.md ← T+3d 数据(cheat-retro 写)
```
- `<date>`**草稿首次落盘日期**(即 `scripts/<id>.md` 的创建日),不是预测日 / 拍摄日 / 发布日。理由:保持 ID 稳定——草稿大改后 hash 变了仍想保持文件可追溯
- `<id>`12 位 sha256 前缀,对**草稿首次落盘的内容**做 hash。用户 edit 草稿后**不变**——便于跨文件引用
- `<short>`:3-8 字中文或英文短名,便于人类辨识
Reconstructed 重做:在 `<short>` 后加 `_redo`,三处都加:
- `scripts/<date>_<id>_<short>_redo.md`
- `predictions/<date>_<id>_<short>_redo.md`
- `videos/<date>_<id>_<short>_redo/`
原文件保留(不删)。
---
## 子 skill 必须做的检查清单
`cheat-predict` 启动时:
1.`BLIND_CHECK` 常量
2. 询问用户该作品当前发布状态(未发 / 已发 < RETRO_WINDOW_DAYS / 已发 ≥ RETRO_WINDOW_DAYS
3. 询问对话历史里是否提到过该作品的任何后续数据(如有,自检对话里有没有 "播放/阅读/点赞/评论" 等关键词)
4.#2#3 命中破坏条件 → 按 `BLIND_CHECK` 模式处理
5. 通过后才允许写 `predictions/*.md`
`cheat-retro` 启动时:
1. 读目标 prediction 文件
2. **先在内存里 cache 住 `## 预测` 段**——后续任何对该文件的写都必须先校验该段未变
3. 抓数据 → 追加 `## 复盘`
4. 写完后**再次校验**:写入后该文件的 `## 预测` 段哈希应等于步骤 2 的 cache。不等 → 报错并回滚
主 SKILL.md
- 用户说出"重写预测" / "改一下预测段" / "你之前预测错了我帮你改" 时,**直接拒绝并解释**,引导改用 `_redo.md` 路径
---
## 异常状态处理
| 场景 | 处理 |
|---|---|
| 预测文件不小心被人手编辑了预测段 | 不自动回滚(破坏更大)。下次 `cheat-retro` 检测到不一致 → 在复盘段追加 `**Integrity warning**: 预测段于 <ISO timestamp> 被外部修改,无法保证盲度`,校准价值降级为"参考",不计入 bump 校准池 |
| 预测文件遗失 / 被删 | git log 找回。找不到 → 在 `rubric_notes.md` 记录"<id> 预测文件遗失,校准池缺该样本" |
| 用户原本是 cold-start,半路想"补"已发作品的预测 | 一律记为 `**Reconstructed retrospective**`,不计入校准池——这是补的不是预测。可作为"观察"记录到 `rubric_notes.md` |
---
## 反模式(必须拒绝的请求)
- 「帮我预测一下,但我先告诉你播放量你来反推就行」 → 拒绝。直接破坏盲度
- 「这条已经发了 5 天数据出来了,但你假装没看到,给我做个预测看会不会准」 → 拒绝。请改用 `_redo.md` 走 reconstructed 路径
- 「上次预测算错了,帮我把概率分布改一下」 → 拒绝。在复盘段说明
- 「能不能跳过 blind check 我有特殊原因」 → 询问原因;只有"格式修复"是合法的 bypass 理由
---
## Why(为什么这套这么严)
盲预测是整个 cheat-on-content 校准循环的**唯一信号源**。一旦预测段被事后修改,所有"哪个维度被验证 / 推翻"的判断都失去基线——你不知道当初是真预测对了,还是事后改对了。
校准价值 = 预测精度 × 预测可信度。
- 预测精度可以靠 rubric 升级慢慢提升。
- 预测可信度一旦破坏不可恢复——**这是为什么 immutability 是 hook 层强制,不是君子协定**。
@@ -0,0 +1,155 @@
# Rubric Bump Validation Protocol(升级验证协议)
被这些子 skill 引用:`cheat-bump`、主 SKILL.md。
这是项目原则 #2 的完整规范——**升级 = 全量重打**。任何对 rubric 的结构性变更都必须走完本协议。
---
## "升级"的定义
下列任一变更触发完整 bump 流程:
- 公式系数变化(ER×1.5 → ×2.0)
- 维度增减(删 NA,加 MS
- 维度定义颠覆性改写("QL=金句数量" → "QL=可挪用句式")
- 归一化常数变化(除数从 8.5 → 9.0)
下列变更**不**触发 bump,但要在 `rubric_notes.md` 标注:
- 维度定义边际细化(QL=5 的门槛从"≥3 句"细化为"≥3 句且分布在不同段")
- 单维度门槛变严的备注(不改公式,只改打分时的判断习惯)
- 锚点样本更新(用新样本作为 5 分标杆)
---
## 何时**可以**提议升级(**Claude 判断为主,下面是参考触发场景**)
任一成立即可提议——但**最终是 Claude 判断观察的成熟度 + 信号强度**,下面只是 default heuristic
1. 某个跨样本观察出现**清晰、可追溯到具体数据点的支持模式**(默认参考 ≥3 样本,但 Claude 可以基于 1-2 个**特别强**的样本提议——如 6x 流量比 / 单一模因 ≥2000 赞这种异常信号)
2. 当前 rubric 在最近复盘中**系统性偏向同一方向**(默认参考 ≥3 次同向,但 Claude 可以基于 1-2 次**极端偏离**提议——如中枢 50w 实绩 5w 这种 10x 偏差)
3. 某候选维度展示了独立预测力(参考样本数:≥3 是稳,但 1-2 个强证据也算)
4. 校准池跨过某分水岭(5 / 10 / 20 / 50)触发常规复审
**Claude 提议 bump 时必须显式标注**:本次提议是 default-aligned(满足上面常规门槛)还是 judgment-driven(基于强信号但样本少)。让用户审视依据。
---
## 何时**禁止**提议升级
下面是**硬约束**——Claude 不能突破:
- 当前正处于 in-progress predictionstate file `in_progress_session != null`)—— 流程纪律
- 上次 bump 后没有任何新校准样本——必须至少 1 个新样本才能再次 bump(避免循环验证自己)
下面是**软建议**——Claude 通常应避免,但有强理由可以打破并显式标注:
- 校准池 < 5 样本时一般不 bump(除非出现强反例)
- 上次 bump 距今 < 3 篇新校准样本时一般不 bump(除非新样本含强反例)
**Claude 软违反时必须:**
1. 在提议中显式说明"我知道默认建议等到 N≥5 / 等到 ≥3 篇新样本,但本次原因是 [X 强信号]"
2. 仍走完整 5 步验证流程(包括跨模型审核)
3. 跨模型审核如果 REJECT → bump 被拒,**不允许"我觉得信号强"绕过**
---
## 完整升级流程(强制 5 步,不允许跳步)
### Step 1:写出新公式的完整方程
不能只说"ER 权重提高",必须把整个方程完整写出,所有系数和归一化常数。
示例(合规):
```
v2.0 composite = (ER×1.5 + SR×1.5 + HP×1.5 + QL + NA + AB + SAT) / 8.5 × 2.0
v2.1 composite = (ER×2.0 + HP×1.5 + MS×1.5 + QL + SR + TS + SAT) / 9.0 × 2.0
```
示例(违规):
- "把 ER 权重提到 2.0" ← 没说其他权重是否变
- "加 MS 维度" ← 没说权重和归一化常数
### Step 2:在校准池上**本地**全量重打分
校准池定义:`predictions/*.md` 中所有有完整复盘段(含 `actual_*` 数据)的文件。
每篇用新公式重新算 composite。**不重新打各维度分数**——维度分数不变(v2 的 ER=5 在 v2.1 也是 ER=5),只重算综合分。
例外:如果新公式增加了维度(如新增 MS),那对每个老样本必须**回追打 MS 分**。这一步会让升级成本随样本数线性上升,是设计上的"升级阻尼"。
### Step 3:计算排序一致性
新 composite 排序 vs 各样本实绩 bucket 排序,必须满足:
- 在 ≥`THRESHOLD` 比例样本上一致(默认 4/5 = 80%)
- 不能把旧公式做对的任一对样本顺序搞反(pairwise no-regression
排序一致性的算法:
1. 用新 composite 给所有校准样本排序
2. 用 actual_plays 给所有样本排序
3. 计算 Spearman rank correlation;同时检查每对 (i, j) 的相对顺序是否颠倒
4. 输出对照表:每个样本的 [新 rank, actual rank, delta]
### Step 4:跨模型独立审核(**强制**,除非 escape hatch
`CROSS_MODEL_AUDIT=true`(默认)时:
1. 调用 `mcp__llm-chat__chat`,把以下打包发给外部 LLM
- 旧公式 + 新公式
- 校准池所有样本的:维度分数、composite(新+旧)、actual_plays、actual_likes、actual_comments、actual_shares
- Step 3 的排序对照表
2. 外部 LLM 独立判定两件事:
- 排序一致性是否真的 ≥ THRESHOLD?
- 新公式相比旧公式是否解释力更强?
3. 外部 LLM 必须输出 **PASS** / **REJECT** + ≥ 100 字理由
4. 本地判定与外部判定**两个都通过**才能进入 Step 5
`CROSS_MODEL_AUDIT=false`:跳过外部审核——**仅在离线/无网时使用**。state file 标记 `last_bump_self_audited: true``cheat-status` 持续提示用户配置外部审核。
### Step 5:升级后清算(Cleanup pass
bump 落地必须一次性完成:
1.`rubric_notes.md` 顶部更新 `**当前版本**` 和**版本速查表**新增一行
2.`rubric-memo.md` 追加"vN → vN+1 升级 Memo" 段写完整 memo(含触发观察 + 证据数据 + 诊断 + 新公式 + 已知局限)。⚠️ 不写入 `rubric_notes.md`——它是 blind sub-agent 白名单,含实绩数据会污染盲评通道
3. 删除驱动本次升级、且已被吸收为正式维度的所有"观察记录"段条目
4. 删除升级过程中被推翻的"观察记录"段条目
5. 仍未解决的"观察记录"段条目 → 移到新版本的"待验证假设"段(保留)
6. 更新所有校准样本的 prediction 文件**底部**追加一行:`**Re-scored under <new-version> on YYYY-MM-DD**: composite=X.XX → Y.YY`(不动预测段、不动复盘段,只追加这一行)
清算不是可选的。rubric_notes.md 是工作台,不是博物馆——见 `observation-lifecycle.md`
---
## bump 被拒后的处理
任一步失败 → bump 拒绝,按下列规范处理:
| 失败位置 | 处理 |
|---|---|
| Step 3 本地排序不一致 | 候选公式回到"待验证"区。**不允许**默默放宽 THRESHOLD(如从 4/5 改到 3/5)—— 那是诚实的 self-deception。THRESHOLD 是协议刚性,与"何时可以提议 bump"的样本数门槛不同(后者可以软违反) |
| Step 4 外部审核 REJECT | 把外部 LLM 的理由完整记录到 `rubric-memo.md` 的"被拒升级 log"段 |
| Step 4 外部审核与本地判定冲突(一个 PASS 一个 REJECT) | 视为 REJECT。冲突意味着至少一方对数据的解读不稳定,不应升级 |
| Step 5 清算无法完成(如某观察既不能删也不能保留) | bump 回滚到 step 0。Note:这意味着新公式还有未捕获的"未解决观察",方案不成熟 |
---
## 升级阻尼(Why this protocol is intentionally hard
校准池每多一个样本,bump 成本线性上升:
- 校准池 3-5 个 → 几乎零成本
- 校准池 20-30 个 → 可感成本
- 校准池 50+ → 痛苦
这是**故意的设计**。频繁 bump = rubric 在追噪声。一个稳定 rubric 的特征是:**bump 越来越罕见,bump 越来越大**——单次 bump 解释多个累积观察,而不是一观察一升级。
参考视频分析项目:v1 → v2 用了大约 4 周(从首次发布到第一次 bump),v2 → v2.1 候选已 4 周仍未升正(在等 6 样本联合验证)。这是健康节奏。
---
## 反模式(必须拒绝的请求)
- 「跳过校准池重打,直接换公式」 → 拒绝。Step 2 不可跳
- 「外部 LLM 审核太麻烦,跳过」 → 仅当 `CROSS_MODEL_AUDIT=false` 显式设置时允许
- 「这次只调一个权重,不算升级吧」 → 任何系数变化都算。Step 1 必须写完整方程
- 「反正是我自己用,不用走全套流程」 → 拒绝。这套流程的价值在你**未来回看**时——你将来想问"为什么 v2.3 没采纳",必须有完整 memo
- 「能不能把 THRESHOLD 从 4/5 改到 3/5 让这次过了」 → 拒绝。改 THRESHOLD 本身是元层级 bump,需要单独走流程
+194
View File
@@ -0,0 +1,194 @@
# Cadence Protocol(节奏协议)
被这些子 skill 引用:`cheat-status``cheat-recommend``cheat-shoot``cheat-publish`、SessionStart hook。
固化"哪天该做什么"——避免用户驱动每一步。让 Claude 在会话开场就能回答"我现在该拍 / 该发 / 该复盘"。
---
## 三层节奏
### 日级(每天 / 每次会话开场)
1. SessionStart hook 自动渲染 4-6 行报告:
- 📦 Buffer 状态(颜色 + 数量)
- ⏰ 待复盘到期项
- 🎯 候选池 top 3(粗排)
- 📅 上次抓热点时间
- ⚠️ 关键 to-do
2. 不主动开始任何动作——等用户决定
### 事件级(T+`RETRO_WINDOW_DAYS` 天到期)
- 任何已发未复盘 + 时间到 → SessionStart 顶部高亮
- 用户给数据(粘 / URL)→ `/cheat-retro` 自动跑
### 周级(用户决定的"集中处理日")
- 抓热点(`/cheat-trends`)刷新候选池
- 检查 rubric bump 触发条件
- 清理 STATUS.md / rubric_notes.md 是否需要清算
---
## Buffer 警戒规则
**Buffer = `state.shoots` 数组长度** = 已拍但未发布的视频数。
`/cheat-shoot` 把视频加进 `state.shoots``/cheat-publish` 移除——两个事件分开使 buffer 跟踪准确。
### 颜色阈值(按 `target_publish_cadence_days` 派生)
`buffer_days = buffer_count × target_publish_cadence_days`
| buffer_days | 颜色 | 含义 | 行动 |
|---|---|---|---|
| < 1 | 🔴 **红** | 警戒——下个发布日可能断更 | **今天必须拍**,且只拍稳分(top 1,不冒险) |
| 1-2 | 🟠 橙 | 偏低 | 应该拍 1-2 条 |
| 3-5 | 🟢 绿 | 正常 | 节奏稳定,可以拍可以休 |
| > 5 | 🔵 蓝 | 积压 | **暂停拍摄**,全力发布存货 + 复盘 |
**示例**
- 用户 cadence = 1(日更),buffer count = 0 → buffer_days = 0 → 🔴
- 用户 cadence = 7(周更),buffer count = 1 → buffer_days = 7 → 🔵(一篇够发七天)
- 用户 cadence = 1buffer count = 4 → buffer_days = 4 → 🟢
### 灵活节奏(target_publish_cadence_days = null
用户在 cheat-init 选"灵活/不固定" → buffer 监控**关闭**。SessionStart 报告只显示"已拍未发:N 条",不显示颜色,不警戒。
---
## 选题策略(`/cheat-recommend` 推 ≥ 2 个时)
每次推荐 2 条时遵循 **1 稳分 + 1 实验性** 原则:
### 第 1 条(稳分)
- 排序 top 1-3
- 类目与最近 N 条已发**不重复**N = max(3, target_publish_cadence_days × 3),避免审美疲劳)
- composite 高 + 议题安全(非 risky
### 第 2 条(实验性)
- 候选池里能验证某个**待验证假设**的样本(如新维度的 A/B 对照)
- 或验证某个**新 pattern**[script_patterns.md](script_patterns.md) 的 Pattern N
- composite 不一定 top,但有"信息价值"——复盘后能让 rubric / pattern 库前进
### Buffer 颜色对推荐的覆盖
| Buffer 颜色 | 推荐策略覆盖 |
|---|---|
| 🔴 红 | **只推稳分 top 1**——不推实验性。"今天能拍出来就行" |
| 🟠 橙 | 1 稳 + 1 实验,但建议优先拍稳分 |
| 🟢 绿 | 标准 1+1 |
| 🔵 蓝 | **暂停推荐**——回 "你 buffer 积压了,先发存货 + 复盘" |
**关键约束**(任何颜色都遵守):
- 同一 category 连发 ≤ 2 条
- 已发过的 candidate(标 done)不推
- 用户主动跳过的 candidate(标 skip6 个月内不推
---
## 节奏元规则
按优先级(高→低):
1. **Buffer 优先于评分**:红色警戒时不要因为"等更好的选题"而断更——拍 composite 7.5 的稳分比"等明天的 9.0"安全
2. **复盘优先于新拍**T+RETRO_WINDOW_DAYS 到期当天**先复盘再考虑拍新的**——否则数据信号丢失,rubric 校准受损
3. **同步优先于积压**:buffer 满(蓝色)时不要再拍,先发掉再说——已拍议题的时效性会衰减
4. **实验性最多 1/天**:每天拍 2 条时至少 1 条是稳分。**不要全实验**——冷启动期实验失败率太高,伤校准节奏
---
## 标准化"今日工作流"模板
### 情况 1buffer 充足 + 没到 T+3d 复盘
```
SessionStart 报告 → user 决定拍/不拍
├─ 拍 → "推荐选题" → cheat-recommend 推 2 个 →
│ user 选 → /cheat-seed 写 draft (cold-start) 或 user 自己写 →
│ user 改写 → script.md → user 拍 → "拍了 videos/<...>/" → cheat-shoot
└─ 不拍 → 等
```
### 情况 2buffer 充足 + 到 T+3d 复盘
```
SessionStart 报告含 ⏰ 复盘提醒 → user 给 video URL 或粘数据 →
cheat-retro 自动跑 → 写复盘段 → 检查 bump 触发条件
├─ 触发 → 提议 /cheat-bump(不强制,用户决定)
└─ 未触发 → 等下个验证样本
```
### 情况 3buffer 红色警戒
```
🔴 SessionStart 第一行警戒 → user 决定
├─ 拍 → cheat-recommend 只推 v 当前 top 1 稳分 → 立即拍
└─ 接受断更风险 → user 自负,cheat-status 持续提示
```
### 情况 4buffer 蓝色积压
```
🔵 SessionStart 报告"积压" → user 决定
├─ 发 → "已发布 https://..." → cheat-publish → buffer -1
├─ 复盘 → 见情况 2
└─ 拍新 → cheat-recommend 拒绝:"你 buffer 已 N 条,先发掉 ≤3 条再来"
```
### 情况 5:周期性集中处理日(用户主动触发)
```
user 说"抓热点" → cheat-trends → 候选池更新
+ user 说"看看 rubric 是不是该升了" → cheat-status 检查同向偏差累计
+ user 说"看看 rubric_notes 行数" → cheat-status 健康度检查
```
---
## 兜底:流程偏离时
如果某天违反节奏(buffer=0 但用户强行不拍 / 积压 ≥10 但用户继续拍),SessionStart 报告**显式标注**
```
❌ 你已 N 天没发新内容(最后一次发布:YYYY-MM-DD),
buffer = 0,你的频道目前处于"事实断更"状态
```
或:
```
❌ 你 buffer 已 N 条但还在新拍,
过去 N 条里有 N 条已超过 X 天未发——存在时效性流失风险
```
**不会自动尝试补救**——只显式报告,由 user 决定如何回到节奏。
---
## 子 skill 责任表
| Skill | 节奏责任 |
|---|---|
| `/cheat-init` | 问 cadence;写 `target_publish_cadence_days`;装 SessionStart hook |
| `/cheat-shoot` | 把 video folder 加 state.shootsbuffer +1 |
| `/cheat-publish` | 从 state.shoots 移除对应项,buffer -1 |
| `/cheat-status` | 计算 buffer + 颜色,输出报告 |
| `/cheat-recommend` | 按 buffer 颜色 + 选题策略给推荐 |
| `/cheat-retro` | 复盘后更新 STATUS(自动 trigger /cheat-status |
| SessionStart hook | 调 /cheat-status 渲染 4-6 行报告,写到 STATUS.md |
---
## 关键差异:cheat-on-content vs 视频分析
| 维度 | 视频分析 | cheat-on-content |
|---|---|---|
| Cadence 来源 | 默认日更(CADENCE.md 硬编码) | 用户自填(cheat-init 问,4 档:日/隔日/周/灵活) |
| Buffer 阈值 | 0/1/2/3-5/6+(按"篇"| 0/1-2/3-5/>5(按"buffer_days"——按用户 cadence 派生) |
| 推荐 2 条策略 | 1 稳 + 1 实验 | 同 |
| SessionStart 报告 | CLAUDE.md 文字约束 + Claude 自觉 | hook 强制 + Claude 读 hook 输出 |
+169
View File
@@ -0,0 +1,169 @@
# Candidate Schema(候选项统一 schema
被这些子 skill 引用:`cheat-trends``cheat-recommend``cheat-init`、所有 `adapters/`
任何"待决定要不要做"的内容素材——不管来自手粘列表 / RSS / Notion / 平台热点抓取——都必须 normalize 成本 schema 之后才进入候选池。这是 `adapters/` 的输出契约。
字段设计参考博主项目的 `articles` 表 schema(私有项目,工具的方法论由此抽象而来)。
---
## 必填字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string (12 chars) | 稳定 hash`sha256(source + normalized_title + url_path)[:12]`。同一条素材在不同时间被抓到 → 同 id |
| `title` | string | 候选项的人类可读标题 |
| `source` | string | 来源标识,格式 `<adapter-type>:<source-name>`,例:`trend:hackernews``pool:notion-mybook``paste:manual` |
| `snapshot_text` | string | 候选项的全文或摘要——**这是打分的输入**,不是 url。adapter 必须负责把 url 拓展成可读文本 |
| `snapshot_at` | ISO 8601 | 抓取/录入这条 item 的时间 |
---
## 可选字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `url` | string | 原始链接(便于追溯) |
| `tier` | enum | `tier1` / `tier2` / `tier3` / `skip` / `risky` / `done`。粗分类,用于过滤 |
| `read_status` | enum | `unread` / `skimmed` / `deep_read` / `done`。处理状态 |
| `category` | string | 自定义分类标签(如"社会"、"家庭"、"学术" |
| `composite_score` | float | 用当前 rubric 打分得到的综合分(如已打) |
| `dimension_scores` | object | 各维度的整数分,键名对齐当前 rubric 的维度(如 `{"ER": 5, "HP": 4, ...}` |
| `scored_under_rubric_version` | string | 打分时用的 rubric 版本号 |
| `predicted_bucket` | string | 粗预测桶(如 `30-100w`),**注意:不是正式预测**——选题阶段的粗略估计,与 `predictions/*.md` 的 immutable 预测完全独立 |
| `predicted_reason` | string | 一句话理由 |
| `note` | string | 自由文本备注,例如"等节点再发"、"待重读"、"风险议题" |
| `rejected_at` / `rejected_reason` | ISO 8601 / string | 用户主动跳过此候选时记录 |
---
## JSON 范例
### Markdown 列表 adapter 输出
```json
{
"id": "a3f2c1d4e5b6",
"title": "为什么我们都讨厌主动联系朋友",
"source": "pool:markdown-list",
"snapshot_text": "[用户从 candidates.md 复制的全文]",
"snapshot_at": "2026-05-04T08:30:00+08:00",
"url": null,
"tier": "tier1",
"read_status": "skimmed",
"category": "社交",
"composite_score": 7.4,
"dimension_scores": {"ER": 4, "HP": 4, "QL": 5, "NA": 3, "AB": 5, "SR": 3, "SAT": 3},
"scored_under_rubric_version": "v0",
"predicted_bucket": "5-30w",
"predicted_reason": "ER=4+QL=5 强金句感,AB=5 普适,但 SR=3 议题不够强",
"note": ""
}
```
### Trend adapter 输出(HN
```json
{
"id": "8c4d92e1f0b3",
"title": "Show HN: I built a tool that predicts whether your video will go viral",
"source": "trend:hackernews",
"snapshot_text": "[文章全文 + 评论 top 5 的摘要]",
"snapshot_at": "2026-05-04T09:15:00+08:00",
"url": "https://news.ycombinator.com/item?id=12345678",
"tier": null,
"read_status": "unread",
"category": "tech-meta",
"composite_score": null,
"dimension_scores": null,
"scored_under_rubric_version": null
}
```
打分前 score 字段全部为 null——是预期的。`cheat-trends` 抓回来后会调 `cheat-score` 给每条算 composite。
---
## Markdown 表示(用户可见的存储格式)
候选池的默认存储是 `candidates.md`(人类可读),不是 JSON。每条 item 是一个 H3 entry
```markdown
### [tier1] 为什么我们都讨厌主动联系朋友
- **id**: a3f2c1d4e5b6
- **source**: pool:markdown-list
- **snapshot_at**: 2026-05-04
- **category**: 社交
- **composite (v0)**: 7.4 — ER=4 HP=4 QL=5 NA=3 AB=5 SR=3 SAT=3
- **predicted bucket**: 5-30w
- **note**:
> [snapshot_text 段,如有]
```
升级到 SQLite 之后(见 `cheat-status` 的升级触发),同样字段走 `articles` 表存储,markdown 视图自动从 DB 渲染。
---
## ID 稳定性的关键规则
**同一条素材在不同时间被不同 adapter 抓到 → 必须算出同 id**。这是去重的基础。
算法:
```python
import hashlib
def candidate_id(source: str, title: str, url: str = None) -> str:
normalized_title = title.strip().lower().replace(" ", "")
url_path = url.split("?")[0].rstrip("/") if url else ""
raw = f"{source.split(':')[0]}|{normalized_title}|{url_path}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
```
注意:
- `source` 取冒号前的 adapter type`trend:hackernews``trend`),不是具体 source name——同一标题被 HN 和 Reddit 都抓到,应判定为同一候选(避免重复打分)
- title 做了 lowercase + 去空格——避免 "Hello World" 和 "hello world" 被算成不同 id
- url 砍掉 query string—— `?utm_source=xxx` 不影响内容
---
## 去重协议
`cheat-trends` / `cheat-recommend` 在写入 `candidates.md` 前必须执行:
1. 计算新 item 的 id
2. 检查 `candidates.md` 是否已含此 id → 跳过
3. 检查 `predictions/*.md` 是否含此 id(已发过)→ 跳过
4. 检查 `.cheat-cache/trends-history.jsonl` 是否含此 id 且 `rejected_at != null` → 跳过(用户已主动拒绝过)
5. 通过则写入
`.cheat-cache/trends-history.jsonl` 是抓取历史的去重缓存,每行一个 JSON recordappend-only。被用户拒绝的候选会在这里保留 6 个月;之后允许重新出现(也许素材在新 rubric 下评估不同)。
---
## tier 的语义
| Tier | 含义 | 对应行动 |
|---|---|---|
| `tier1` | 强候选,应推荐 | 进入 `cheat-recommend` 排序池 |
| `tier2` | 中等,备选 | 进入排序池但权重低 |
| `tier3` | 弱候选,备而不用 | 不进推荐池,留作长尾 |
| `skip` | 用户主动跳过 | 不再出现 |
| `risky` | 议题敏感 / 平台风控风险 | 推荐时额外标注,需用户确认 |
| `done` | 已发布 | 移出候选池,由 prediction file 接管 |
**Cold-start 期间所有 item 默认是 `unread`/`null tier`**——直到用户或 `cheat-score` 给出 composite 后才能粗分类。**未打分的 item 不应出现在 `cheat-recommend` 输出**——避免推荐没读过的素材。
---
## adapter 实现契约
任何 `adapters/` 下的 adapter 都必须:
1. 实现 `fetch() → List[Candidate]` 接口(伪签名,实际是 markdown 文档描述的协议)
2. 输出符合本 schema 的 items
3. 自己负责把 url / 短摘要拓展成可读 `snapshot_text`——**adapter 不输出"光秃秃的 url"**
4. 优雅降级:如配置缺失(API key、cookie),返回空列表 + 在 stderr/log 写明原因,**不抛异常**
详见 `adapters/HOWTO.md`(待批次 3 写)。
+149
View File
@@ -0,0 +1,149 @@
# Data Source Routing — 热点工具的触发与路由协议
被 cheat-seed / cheat-trends 引用。规定**何时**调热点工具、**调哪个**、**不调时怎么办**。
---
## 核心哲学
> **热点工具是"前置素材库",不是"主菜单"。**
>
> - 用户在**内省**(讲自己的经历 / 思考动机)→ **不调**,避免外部信息污染
> - 用户在**找素材**(没想法 / 要批量 / 显式抓热点)→ **调**,按 content_form 路由数据源
> - 用户在**确认 angle**(讲了时事话题)→ **不主动调**,让用户决定要不要外部数据作参考
设计目的:保护 cheat-seed 的核心论点——"好内容来自用户的真实经历,AI 不凭空 brainstorm"——同时不让"完全没想法"的新博主卡死。
---
## 触发矩阵(被 cheat-seed Phase 1 引用)
| cheat-seed Mode | 默认调? | 触发条件 |
|---|---|---|
| **Mode A**(用户给了具体经历/topic) | ❌ 默认不调 | 仅当用户讲的本身是时事话题(含产品名/人名/事件名 + 时间词)+ 用户**主动同意** |
| **Mode B**(方向不具体,问"为什么") | ❌ **永远不调** | 这阶段用户在内省,外部素材是噪音 |
| **Mode C**(完全没想法) | ✅ 默认调 | Mode C 的核心动作就是把外部素材摆出来 |
| `--batch N` | ✅ 默认调 | 批量 brainstorm 必须有 anchor |
| `/cheat-trends` 显式 | ✅ 调 | 主入口,无需解释 |
| `/cheat-recommend` | ❌ 默认不调 | 已有 pool;除非 pool >7 天没更新 → 提示先 trends |
---
## 时事话题判定(Mode A 灰色场景用)
让 Claude 判断,**不写正则白名单**:
| 信号 | 含义 |
|---|---|
| 含**专有名词**(人名 / 产品名 / 事件名) | 强信号——可能是时事 |
| 含**时间词**"今天" / "刚" / "最近" / "刚刚发生" | 强信号 |
| 含**结构词**"对比" / "回应" / "事件" | 弱信号 |
| 仅含通用名词 + 个人经历词("我" / "昨天" / "我同事") | 反信号——是长青个人经历,**不是时事** |
判定结果:
- **强信号** → 询问用户"要不要拉一下这话题的舆论风向作参考"
- **弱信号 / 模糊** → 不主动询问,直接进 Mode A 深挖
- **反信号** → 100% 不调
跟 [bump-validation-protocol.md](bump-validation-protocol.md) 的"软规则、Claude 判断"哲学一致。
---
## 数据源路由(按 content_form
[adapters/trend-sources/](../adapters/trend-sources/) 目前有两个一等公民 + 一个保底:
| Adapter | 适合的 content_form |
|---|---|
| [`aihot`](../adapters/trend-sources/aihot.md) | `tutorial-builder` / AI 行业评论 / AI 教程 / AI 产品测评 |
| [`trendradar-mcp`](../adapters/trend-sources/trendradar-mcp.md) | `opinion-video` / `long-essay` / `short-text` / `podcast` / `other`(生活/职场/文化) |
| `manual-paste` | 永远的 fallback——用户粘 URL/标题列表 |
### content_form → 主调 + 备调矩阵
| content_form | 主调 | 备调 | 不调 |
|---|---|---|---|
| `opinion-video` | trendradar-mcp | aihot(仅当话题与 AI 行业相关) | — |
| `long-essay` | trendradar-mcp | aihot(同上) | — |
| `short-text` | trendradar-mcp | aihot(同上) | — |
| `podcast` | trendradar-mcp | aihot(同上) | — |
| `tutorial-builder` | **aihot** | trendradar-mcp(仅当涉及通用工具/产品发布) | — |
| `mixed` | 两个都调 | — | 由 Claude 判断每条候选属于哪个垂类 |
| `other`(美食/妆教/剧情/...| trendradar-mcp | — | aihot(与 AI 无关) |
### 用户层覆盖
`.cheat-state.json``enabled_trend_sources` 字段是**显式开关**
```json
"enabled_trend_sources": ["aihot", "trendradar-mcp", "manual-paste"]
```
数组里有的才会被调。空数组 → 仅走 manual-paste。
cheat-trends 显式调用时支持 override`/cheat-trends — sources: aihot`(仅这次用 aihot)。
---
## 失败降级链
```
[cheat-seed Mode C 触发拉热点]
[按 content_form 选主调]
├─ 主调成功 → 拿数据 → 进流程
├─ 主调失败(API down / MCP 没装 / 超时)
│ ↓
│ [按 content_form 选备调]
│ ├─ 备调成功 → 拿数据 → 提示用户"主调用不了,用了备调"
│ └─ 备调也失败 → 走 manual-paste 兜底
│ ↓
│ [询问用户:"今天看到啥可以拍的?粘几条 URL/标题给我"]
└─ 用户当前没启用任何 source → 提示如何启用 + 这次直接走 manual-paste
```
**关键纪律**:所有失败都**不抛异常**。cheat-seed 永远能跑——区别只是有没有外部素材。
---
## Token 成本意识
热点 API 调用**有成本**aihot 是 token / trendradar-mcp 是 MCP 调用 + LLM context)。判定原则:
| 场景 | 调用频率 |
|---|---|
| Mode C 触发 | 每次会话最多 1 次(拿数据后 cache 在内存) |
| Mode A 灰色场景 | 用户同意才调,1 次 |
| `--batch N` | 1 次拿足够候选 |
| 用户连说"再来一批" | 第二次允许,第三次提示"要不要换 query 角度" |
不要在同一会话里反复调同一个端点——那是浪费。
---
## 与 candidates.md 的关系
热点工具拉回的数据**最终落到** [candidate-schema.md](candidate-schema.md) 定义的 `candidates.md`
```
[trend tool] → items
→ 去重(vs candidates.md / predictions/ / .cheat-cache/trends-history.jsonl
→ 粗打分(cheat-seed 内联 rubric
→ 写入 candidates.md(带 source 字段标明来自哪个 adapter
```
cheat-seed Mode C 拿到数据后**不**直接进 brainstorm,先入 candidates.md,再让 Claude 从池子里选。这样数据可追溯、可被后续 cheat-recommend 复用。
---
## 给 maintainer 的扩展指南
新增一个 trend source
1.`adapters/trend-sources/<name>.md`,按现有 aihot.md / trendradar-mcp.md 的格式
2. 在本文件"数据源路由"段加一行——明确该 adapter 适合的 content_form
3. 不需要改 cheat-seed 内部逻辑——按 `enabled_trend_sources` 自动启用
4. CHANGELOG 标 MINOR
不要把硬编码"aihot"/"trendradar-mcp" 写进 cheat-seed SKILL.md——保持 adapter 模型可扩展。
+149
View File
@@ -0,0 +1,149 @@
# Migration Protocolschema 演进哲学)
`cheat-migrate` skill / `cheat-init` / SessionStart hook / 维护者引用。规定如何安全演进 `.cheat-state.json` schema 而不让老用户被打断。
---
## 核心原则
1. **每个 release 必须能让老用户的旧 state 工作**——通过 migrate 升级,或通过 `state.get(field, default)` 兼容
2. **MINOR 改动 = 加字段 / 软化 enum**;老 state 不跑 migrate 也能工作(字段缺失用默认值),跑了让 state 完整
3. **MAJOR 改动 = 删字段 / 重命名 / 改语义**;老 state **必须**跑 migrate,否则 skill 读到不一致字段会出错
4. **不允许跳版**;多版升级必须按顺序应用每个 step。每步幂等
5. **失败停在原地**;不回滚,让用户在断点修复后继续
6. **schema_version 是单调递增**;不允许降级(如需降级,cp 历史 git 快照)
---
## 何时算 MINOR vs MAJOR
### MINOR 范围(不需要 migrate 也能跑老 state
- 新增字段(默认值定义良好,老 skill 不读它也不出错)
- 软化 enum 取值(如 `"strict" / "lenient"` 加第三个 `"adaptive"`,老值仍合法)
- 给字段加新可选取值(如某个 list 字段加新元素)
- 改默认值(不改语义)
### MAJOR 范围(必须跑 migrate
- **删除字段**——老 skill 仍写它,新 skill 不读它,会出歧义
- **重命名字段**——老/新 skill 看不到对方写的
- **改字段语义**(如 `mode` 从 enum 改为整数;`baseline_plays` 从 int 改为 list
- **改 enum 取值**(如 `"opinion-video"` 改成 `"opinion_video"`,老值不再合法)
- **拆字段 / 合字段**
> 模糊地带建议**保守判定为 MAJOR**——多写一份 migration 文件比让用户的 state 出错更好。
---
## 维护者 checklistbump schema 时必做的 4 件事
每次准备 release 时如果改了 state schema
### 1. 改 cheat-init 写新 state 的硬编码 schema_version
```diff
- "schema_version": "1.1",
+ "schema_version": "1.2",
```
位置:`skills/cheat-init/SKILL.md` Phase 3 的 state 写入段。
### 2. 改 migrations/registry.md 的 LATEST_SCHEMA 标记位
```diff
- LATEST_SCHEMA = "1.1"
+ LATEST_SCHEMA = "1.2"
```
并在"版本链"表追加新行:
```
| 1.1 | 1.2 | NO/YES | [1.1-to-1.2.md](1.1-to-1.2.md) | 一句话描述 |
```
### 3. 写 migrations/<old>-to-<new>.md
4 段必填(参考 `1.0-to-1.1.md` 模板):
- WHAT changed
- WHY
- HOW (Claude steps for /cheat-migrate)
- Manual fallback
> 写不出 4 段 = 改动太复杂没想清楚 = 不该 release 这次 schema bump。
### 4. CHANGELOG.md 标版本号 + 链接
```markdown
## [0.2.0] — YYYY-MM-DD
### BREAKING / MINOR
- schema_version 1.1 → 1.2: <一句话描述>。迁移指南:[migrations/1.1-to-1.2.md](migrations/1.1-to-1.2.md)
- ...
```
MINOR 用 `### MINOR`MAJOR 用 `### BREAKING`,要醒目。
---
## skill 内部怎么读 state(防御式编程)
每个 skill 读 state 时**必须**用 `state.get(field, default)` 模式:
```python
# 好
target_cadence = state.get("target_publish_cadence_days", None)
benchmark_status = state.get("benchmark_status", "none")
shoots = state.get("shoots", [])
# 坏(老 state 没这字段会 KeyError
target_cadence = state["target_publish_cadence_days"]
```
理由:
- MINOR 升级时老 state 缺新字段——`get` 模式让 skill 自动用默认值
- 用户手改 state 删了字段——同上
- 减少 skill 内"必须先迁移才能跑"的强依赖
**例外**:核心标识字段允许直接索引(如 `state["schema_version"]``state["rubric_version"]`)——这些缺失意味着 state 文件根本不合法,应该明确报错。
---
## SessionStart hook 的角色
hook 在每次会话开始时检测:
```bash
state_schema=$(jq -r '.schema_version // "unknown"' "$STATE_FILE")
if [[ "$state_schema" != "$LATEST_SCHEMA" ]]; then
echo "⚠️ schema 版本不一致:state=$state_schema, skill 期望=$LATEST_SCHEMA"
echo " 建议跑 /cheat-migrate 升级(不阻塞继续工作)"
fi
```
**非阻塞**:用户可以选择"先继续工作,回头再跑 migrate"。MINOR 不一致时大部分功能仍能跑;MAJOR 时部分 skill 可能报错——这时再跑 migrate 也来得及。
---
## 给开发者:避免 schema 频繁 bump 的实践
不是每个改动都需要 schema bump。下面是哲学:
- **优先 MINOR**:能加字段就加字段,少删字段。删字段让老用户不爽
- **批量 bump**:积攒 3-5 个 MINOR 一起 release 比每次小改都 bump 要友好
- **延迟 bump**MINOR 字段如果 90% 用户用不到,**不**急着 bump schema——可以让该字段 `state.get(field, default)` 默默 work,等下次 release 顺路 bump
- **避免 MAJOR**:能用 MINOR 解决的绝不上 MAJOR。例:与其重命名字段,不如保留旧字段 + 加新字段(旧的标 deprecated,下个 MAJOR release 才删)
---
## 备份保留策略
`/cheat-migrate` 写之前会备份到 `.cheat-state.json.backup-<timestamp>`
备份保留多久:
- 用户跑 `/cheat-status` 时,如果有备份 + state 已稳定运行 N 天 → 提示"可以清理 N 个旧备份"
- `/cheat-init` 重 init 时清理所有旧备份(既然要重 init,老备份意义不大)
- 用户手动 `rm .cheat-state.json.backup-*` 永远 OK
不入版本控制:`.cheat-state.json.backup-*` 应在 `.gitignore` 里(已含 `.cheat-state.json` 通配规则)。
+210
View File
@@ -0,0 +1,210 @@
# Observation Lifecycle(观察生命周期协议)
被这些子 skill 引用:`cheat-retro``cheat-bump`、主 SKILL.md。
这是项目原则 #3 的完整规范——**rubric 是工作台,不是博物馆**。任何对 `rubric_notes.md` 中观察条目的增删都必须遵循本协议。
---
## 三个生命阶段
每条观察都在下列状态之一:
```
[新增] → [观察记录] → [跨视频观察] → [规律沉淀] / [被吸收为维度] / [被推翻]
[待验证假设](暂存)
```
| 阶段 | 在 rubric_notes.md 的位置 | 触发 |
|---|---|---|
| **观察记录** | `## 观察记录` 段 | 单次复盘后写入。每篇视频复盘对应一条 |
| **跨视频观察** | `## 重大跨视频观察` 段 | 同一个 pattern 在 ≥2 个样本里出现 |
| **规律沉淀** | `## 规律沉淀区(高置信度)` 段 | 已有 ≥2 样本支持且**通过升级验证流程**(即被吸收进维度或被显式确认) |
| **待验证假设** | `## 待验证假设` 段 | 单样本观察暂存,等更多样本 |
---
## 升级到下一阶段的门槛(**Claude 判断为主,下面是参考默认**)
| 从 → 到 | 默认参考门槛 | Claude 判断信号强度(可软违反) |
|---|---|---|
| 观察记录 → 跨视频观察 | 同 pattern ≥2 样本 | 1 样本 + 评论区 ≥2000 赞的强模因证据也可升 |
| 跨视频观察 → 待验证假设 | 单样本 + 强信号但还没复现 | — |
| 跨视频观察 → 规律沉淀 | ≥2 样本 + 不需要改公式 | 1 样本 + 强反例(≥3x 偏差)也可升(标 `**Single-sample, high-confidence**` |
| 跨视频观察 → 维度(不再单独存在) | ≥3 样本 + 通过 bump 流程被吸收 | 同 cheat-bump 的 READINESS_HEURISTIC |
| 任意 → 删除 | 被新数据推翻 / 被吸收为维度 / 被沉淀为规律 | `cheat-bump` cleanup pass,或单独操作 |
**核心原则**:样本数是**信号强度的代理指标**,不是信号本身。3 个清晰、可追溯到具体数据点的样本 > 10 个零碎、低置信度的样本。
**Claude 软违反的纪律**
- 标注 `**Promoted with N samples (default expects M)**: <为什么仍然成立>`,让用户审视
- 不连环软违反——如果 Claude 在最近 3 次升格里有 2 次以上软违反,cheat-status 提示"你的观察升格判断可能太激进,建议回到默认门槛 review"
### Sample size → 允许的动作(细化分级)
按校准池**总样本数**决定能做什么改动——粗暴改大动作需要更多证据:
| 校准池规模 | 允许的动作 | 备注 |
|---|---|---|
| **1 个** | 记录"单次观察" | 单点不能触发任何规则改动,只能作为种子 |
| **2-4 个** | 提炼"候选规律" + 升级到"跨视频观察"段 | 仍不能改公式 |
| **5-9 个** | 修正维度定义(**定性变更**——如把 SR=5 的门槛说更严)| 不动权重数值,只改判断习惯。**第一次正式 bump 也在这一档**(rubric 形态首次成型) |
| **10-19 个** | 微调权重 ±0.2(**定量变更**| 如 ER ×1.5 → ×1.7;新增 / 删除维度仍属"定性大动作",需要更多样本 |
| **20+ 个** | 用回归反推权重(**数据驱动**| Spearman correlation 之类,可作为 bump 依据 |
**关键纪律**
- 不要在 N=5 时就用回归——5 个点拟合 7 维公式必过拟合
- 不要在 N=20 时还用直觉调权重——已经有数据信号了
- bump 协议(`bump-validation-protocol.md`)的 `MIN_SAMPLES_FOR_BUMP=5` 是**形态首次成型**的最低门槛,不是"开始数据驱动"的门槛——后者要 N≥20
---
## 删除规则(最容易做错的部分)
**两类条目必须删,不能保留**
### 类型 A:被吸收为维度的观察
例:v2.1 升级时,"观察 E(致谢段二创量级是 ER=5 的最强外部证据)"被吸收为新维度 **MSMemetic Shareability**
→ 升级落地后,**删除**"观察 E"这条观察记录。维度 MS 本身就是新归宿。
**理由**:保留观察 = 同一概念在文件里出现两次(一次作为维度,一次作为观察)。读者会困惑:这是历史还是仍生效的规则?
### 类型 B:被新数据推翻的观察
例:观察 X 提议"长视频天花板低",后来 4 个长视频样本都破 50w → 推翻。
→ **删除**这条观察。
**理由**:保留 = 让未来的你或其他读者基于错误规则打分。
---
## 必须保留的条目
下列条目**不删**,留在原处或迁移到合适段:
- **未解决的观察**(既未被吸收也未被推翻)→ 跨升级时迁移到新版本的"待验证假设"段
- **历史校准事件**(如"v1 → v2 升级是因为房价 259w 严重低估")→ 保留在版本日志的"升级 Memo"段,**不**保留在观察段
- **本版本仍生效的规律**(已沉淀到"规律沉淀区")→ 保留
---
## 不允许的反模式(**必须拒绝**)
下列模式都是"博物馆冲动"——把 rubric_notes.md 当历史档案:
| 反模式 | 为什么不行 |
|---|---|
| `~~ER 权重 1.5~~` `**改为 2.0**`(带删除线的旧值) | 用 git history 看旧值,不要在文件里堆 |
| "我曾经以为 SR 重要,但其实..." | 这种考古条目让读者读完不知道当下规则是什么 |
| "v1 时代 NA 是关键,v2 后发现不是" | 同上。删掉 NA 相关条目,留版本 memo 解释为什么 NA 被砍 |
| "保留这条观察作为反例" | 反例的位置是版本 memo 的"被推翻假设"小节,不是观察段 |
| "下个 bump 再删,先放着" | 拒绝。bump 落地的同一次操作里删干净 |
git history 才是真正的归档。`rubric_notes.md` 是当前生效规则的快照——读者打开它应该看到**今天该怎么打分**,而不是过去几个月的演化史。
---
## Blind channel leak guard**任何 skill 写 rubric_notes.md 时必须遵守**
`rubric_notes.md` 是 cheat-score-blind sub-agentchannel B)的**白名单**——sub-agent 读这个文件给 script 打分时,文件内容不能含**已发布作品的实绩数据**,否则 sub-agent 的"盲"被破坏。
**禁止写入 rubric_notes.md 的 pattern**
| 模式 | 例子 | 替代位置 |
|---|---|---|
| 真实视频标题 + 实绩 | "「停止期待」播放 71.1w" | rubric-memo.md |
| 派生证据带命名锚 | "派生证据:「她不一样」MS=5 → 实绩 124.8w" | rubric-memo.md |
| 校准池重打表 | bump 时的样本对照表 | rubric-memo.md |
| 跨模型审核引用含数字 | "audit: 「老板废话」rank 一致 ✓" | rubric-memo.md |
| 含 `\d+w` / `\d+万` / `\d+M` / `\d+k` 的数字(**除 bucket 边界** | "实绩 13.7w" | rubric-memo.md |
**允许的 pattern**(通用语言):
- 公式:`(ER×2.0 + HP×1.5 + ...) / N × M`
- 维度定义:`ER(情感共鸣):0=无情绪 / 3=中等共鸣 / 5=极端共鸣`
- 派生证据**抽象化**`派生证据:高抽象密度样本 → CC=1 → 低 reach`
- Bucket 边界(数字属于规则不属于实绩):`5-30w / 30-100w / 100-150w / >150w`
- 观察 ID + 一句话:`观察 E:开头 5 秒含问句 → ER+`(不带样本名 / 不带实绩)
**强制 cheat-bump Phase 5 末尾自检**:写完 rubric_notes.md 后跑 `grep -E '\\d+\\s*[wWmMkK万]|播放|实绩|实际'` → 命中 → abort + 回滚。这是硬约束。
历史背景:PR #11 引入 cheat-score-blind 时漏算了这条——cheat-bump 把 Memo 写进 rubric_notes.mdsub-agent 通过白名单读到实绩。PR #12 修复(拆 file)+ 加本段约束防止将来再犯。
---
## cleanup pass 的强制时机
`cleanup pass` = 把所有满足"可删除"条件的观察一次性清掉。
强制触发:
- **bump 落地的最后一步**(见 `bump-validation-protocol.md` Step 5
- 用户显式触发"清算"操作(罕见)
非强制但建议:
- 校准池每加 5 个样本,主动审视"观察记录"段,看哪些已经可以升格 / 删除
- `cheat-status` 看板检测到 `rubric_notes.md` 行数超过 500 时,建议清算
---
## 行数预算
健康 `rubric_notes.md` 的体积参考(目标):
| 校准池规模 | 健康行数 | 警戒行数 |
|---|---|---|
| 0-5 篇 | 100-200 | >300 |
| 5-20 篇 | 200-400 | >500 |
| 20-50 篇 | 300-500 | >700 |
| 50+ 篇 | 400-600 | >800 |
行数超警戒 → 必跑一次 cleanup pass。这不是为了好看,是为了**读者打分前能在 60 秒内读完核心规则**。
---
## 与"观察记录"模板的对齐
每条观察记录的标准模板(`rubric_notes.template.md` 也用这个):
```markdown
### YYYY-MM-DD [标题简称] (id) — [一句话定性,如"验证 ER 主导"]
- 预测:composite=X.XXbucket=Y
- 实绩:播放 / 点赞 / 评论 / 转发(带 T+Nd 标注)
- Top 评论关键词:[简短摘录 + 赞数]
- 判断:哪个维度被验证 / 推翻?为什么?
- Rubric 调整:[如果有,写明 "下次打 XX 类文章时改 YY"]
- 详见:[predictions/<file>.md]
```
跨视频观察的模板:
```markdown
### 观察 X — [一句话规律]
**证据**
- 样本 1(composite, 实绩):核心数据
- 样本 2(composite, 实绩):核心数据
- 样本 3(composite, 实绩):核心数据
**假设**:[如果规律成立,意味着什么 rubric 调整]
样本数:N 个,[已超 / 待补]。
```
---
## 与 bump 协议的衔接
`cheat-bump` 在 Step 5cleanup pass)必须按以下顺序处理观察段:
1. 列出所有 `## 观察记录` 段条目
2. 对每条问 3 个问题:
- 它是否被本次 bump 吸收为正式维度?→ 删
- 它是否被本次 bump 的新数据推翻?→ 删
- 它是否仍未解决?→ 迁移到"待验证假设"段
3. 列出所有 `## 重大跨视频观察` 段条目
4. 对每条问相同 3 个问题
5. 把"已被验证但不需改公式"的规律 → 迁移到"规律沉淀区"
6. 最后**重新阅读** `rubric_notes.md` 全文,确保读者打开能在 60 秒内理解当下打分规则
+332
View File
@@ -0,0 +1,332 @@
# Prediction Anatomy(预测日志解剖)
被这些子 skill 引用:`cheat-predict``cheat-retro``templates/prediction.template.md`
**所有预测都用统一格式**——7 个必备组件 + 复盘段。Confidence 等级(基于 `calibration_samples` 派生,见 [state-management.md](state-management.md) 的 confidence 表)作为 header 字段标注,告诉用户这次预测有多可信,**但不改变预测格式本身**。
> **为什么不分 cold-start 简化版 / complete 完整版(旧设计的弃疑)**:
> 把 cold-start 切成简化版是基于"前 5 篇 bucket 数字是 false precision"的担忧。但更好的解决方案是**显示 confidence 等级**——天气预报永远报具体温度,再标置信度,不会因为预报员经验少就不给数字。
> 双版本切换还引入了"第 5 篇突然解锁完整版"的复杂度跃迁——用户体验割裂。统一格式 + 渐进信心标注更平滑。
参考真实样本:参考博主项目(私有,含 25+ 视频校准)—— 工具的设计灵感与 rubric 权重均来自此实测。
---
## 7 个必备组件
### 组件 1File header(文件头)
```markdown
# <标题> — 预测日志
**Article ID**: <12 位 hash> (sha256 of scripts/<id>.md initial content, 取前 12)
**Title**: <作品完整标题>
**Rubric Version**: **v0** | **v1** | **v2** | ...
**预测时间**: 2026-05-04(基于最终稿)
**Script Path**: scripts/2026-05-04_<id>_<short>.md
**Script Hash**: <sha256:12 of script content at predict time>
**Target Duration (s)**: 240 (state.typical_duration_seconds 派生)
**Actual Script Length**: 980 字 (从 Script Path 文件读)
**Calibration Samples (at predict time)**: 3
**Confidence**: 🟡 偏低 (中枢 ±40%,可作为参考之一)
**Prediction Basis**: pre_shoot ← 或 `post_shoot_pre_publish`v2 段)
**Scored By**: claude ← 或 `claude+user_override`
**BlindScored By**: subagent-v1 ← 或 `main-claude-self` / `mixed`
**BlindScore Disagreement**: <inline JSON 见下方>
**User Override**: none ← 或列出被覆盖字段
**预测时数据状态**: **blind**(未看任何 <平台> 实际播放数据)
```
`BlindScore Disagreement` 字段是 inline JSON 数组,**每维度一行****delta=0 也必须记**
```json
[
{"dim": "ER", "blind": 5, "self": 5, "delta": 0, "decided_as": 5},
{"dim": "SR", "blind": 3, "self": 4, "delta": 1, "decided_as": 3},
{"dim": "AB", "blind": 2, "self": 4, "delta": 2, "decided_as": 4, "user_decision": "b"},
{"dim": "HP", "blind": 5, "self": 5, "delta": 0, "decided_as": 5}
]
```
- `blind`sub-agent 给的分
- `self`:主 Claude 自估(Phase 2 末尾 internal 估值,不落盘的那份现在落盘了——这是必要的诚实代价)
- `delta`|blind - self|
- `decided_as`:进入 composite 计算的最终值
- `user_decision`(如有):Phase 2.5 用户裁定时的选项 `a` / `b` / `c <number>`——只在 delta ≥ DISAGREEMENT_THRESHOLD 时出现
字段必填规则:
- `Rubric Version` 必填——将来 v3 时代回看 v2 预测,没有版本号就无法公平对比
- `预测时数据状态` 必填——明确声明 blind 是 immutable 承诺的前提
- `Script Path` 必填——指向 `scripts/<id>.md`pre-shoot 草稿)
- `Script Hash` 必填——cheat-shoot 时再 hash `videos/<id>/script.md`,不一致 → 复盘段加 integrity warning
- `Calibration Samples` + `Confidence` 必填——告诉读者这次预测有多可信。**Confidence 自动派生**自 calibration_samples(见 state-management.md
- `Prediction Basis` 必填——`pre_shoot` 为标准盲预测;`post_shoot_pre_publish` 为 v2 拍后改稿重判(仍未见数据,但软盲)
- `Scored By` 必填——告诉读者这次预测是 Claude 全自动还是用户介入改过:
- `claude`Claude 主动打分 + bucket + 概率,用户 review 后回 "ok" 接受
- `claude+user_override`:用户在 review 阶段挑刺改了某些字段
- **`BlindScored By` 必填**——本次维度分由谁打:
- `subagent-v1`:通过 Task tool 调 cheat-score-blind sub-agent 拿到的盲打分(默认,Phase 2 路径)
- `main-claude-self`:用户 `--skip-blind` flag 或 Phase 2.5 选 b(信主 Claude 自估)——同时 `state.last_prediction_self_scored=true`
- `mixed`Phase 2.5 用户选 c 给个别维度自定分,其他维度仍走 sub-agent
- **`BlindScore Disagreement` 必填**——上方 JSON。**所有维度必记**(即使 delta=0),不允许"只记差异大的"。理由:复盘时按 delta 分布分析"哪类维度 sub-agent 与主 Claude 系统性分歧"是 rubric 演进的重要信号
- `User Override` 必填(如有覆盖)——列出哪些字段从 X 改成 Y,附用户给的理由。复盘时这个字段帮诊断:用户的覆盖被实绩验证(用户直觉准)→ rubric 可能漏了什么
---
### 组件 2:输入快照(Input snapshot
记录**预测时**的稿子状态——尤其是用户的最终改动。
```markdown
## 输入快照
**分数 (vN)**: ER5 / HP5 / QL5 / NA3 / AB5 / SR2 / SAT4 → composite=**8.24**
**用户改写要点 vs Claude 草稿(如有)**:
- **开头**user 砍掉 EWDM 模型名和铺垫
- **砍掉**:[具体段落 / 概念名 / 铺垫]
- **保留**:[关键的金句 / 致谢段 / 主体结构]
- **节奏**:比草稿 [紧 / 松] 约 N%
```
> 如果是用户从零写的(没用 cheat-seed),这一段写"用户原创稿,无 Claude 草稿对照"。
---
### 组件 3:预测主体(Prediction)⭐ immutable 段
这是 immutable 段的核心。`hooks/prediction-immutability.sh` 拦截这段往后到下一个 `##` 的所有 Edit。
```markdown
## 预测 v1
**Bucket**: `30-100w`
**内心概率分布**:
- `<5w` → 3%
- `5-30w` → 22%
- **`<headline bucket>` → 55%**(中枢 ~50w
- `>100w` → 17%
- `>150w` → 3%
**一句话 reason**:
> ER=5+AB=5 暗恋普适受众;IS 直接锁定;7.3天+零信号反转+MVP金句情绪曲线完整;SR=2 无社会议题托底是天花板瓶颈;预计 40-60w 中枢。
```
强制要求:
- **Bucket** 必须是预定义的 5 个之一
- **概率分布** 必须加起来 100%——这是逼你诚实的工具
- **中枢** 是该 bucket 内的点估计,便于复盘判断"偏高 / 偏低"
- **一句话 reason** 浓缩到 DB 字段,便于跨样本检索
**关于 cold-start 期的 bucket**calibration_samples 少 → 概率分布**应该更平**(如 30/30/20/15/5 而非 5/40/45/8/2)。Confidence 低不代表跳过 bucket,而代表对 bucket 该有合适的不确定度。
---
### 组件 4:推理因素表(Reasoning factors
每个驱动判断的因素 + 方向 + 置信度 + 说明。
```markdown
## 推理因素
| 因素 | 方向 | 置信度 | 说明 |
|---|---|---|---|
| ER=5 | 强 + | 高 | "半夜三点翻聊天记录"极端具象 |
| IS 钩子 | 强 + | 高 | "仅影响 X 的人"一句锁定受众 |
| SR=2 | 强 - | 高 | 无社会议题托底,纯个人情感天花板有限 |
| 数据+金句路线 | 中 ? | 低 | 对算法友好度未验证 |
```
**置信度** 分三档:高(强证据 + 多锚点支持)、中(有理由但样本少)、低(凭直觉)。
- 复盘时如果"低置信度"因素被验证 → 直觉强
- 如果"高置信度"因素被推翻 → rubric 有 bug
---
### 组件 5:锚点对比(Anchor comparison
找 2-4 个 composite 接近的旧样本,列出它们的实绩。
```markdown
## 锚点对比
| 对照样本 | composite | 实绩 | 异同 |
|---|---|---|---|
| 仓鼠 | 9.41 | ~150w | composite 低 1.17,但路线差异大(类比讲解 vs 数据+金句) |
| 房价 | 9.41 | 259w | SR 差 3 分(2 vs 5 |
| 谁问你了 | 8.24 | T+8d 11.7w | 同 composite 但 ER 5 vs 3SR 2 vs 4 |
```
锚点的价值:抓出公式抓不到的错误。
**校准样本不够时**< 2 个 composite 邻近的已发样本):
```markdown
## 锚点对比
校准池只有 N 个样本,无 composite ±0.5 邻近样本。**锚点对比 N/A**——
注意这次预测的 confidence 标注是 🟡 偏低 / 🔴 极低,bucket 中枢仅供参考。
```
**仍然写出这段**——告诉读者锚点为何缺。不是把段落删掉。
---
### 组件 6:反事实场景(Counterfactual scenarios
每个可能的 bucket 写一段"如果落在这里,意味着什么"。
```markdown
## 反事实场景
**如果爆 `>X w`**X% 预期):
- [验证什么假设]
- [推翻什么假设]
- [可能新增什么 rubric 维度]
**如果落在 `headline bucket`**X% 预期):
- [基准线验证什么]
**如果跌到 `<X w`**X% 预期):
- [推翻什么核心判断]
**如果 `<<X w`**X% 预期):
- [极端场景的可能解释]
```
为什么必填:复盘时**实际落在哪个 bucket** 直接告诉你 rubric 的哪个假设被测试。没有反事实场景,复盘退化为"这次准 / 不准"——没有诊断价值。
---
### 组件 7:关键校准假设(Critical calibration hypothesis
可选但强烈推荐:把这次预测当成一次实验,明确写下"如果 X 发生,证明 Y"。
```markdown
## 关键校准假设(对比谁问你了)
两篇同 composite=8.24,差异:
- 怎么停止期待:ER=5 / SR=2
- 谁问你了:ER=3 / SR=4
**我押:本篇 > 谁问你了(比率 1.5-2x**
如果反过来 → rubric 里 SR 权重应上调,ER 权重应下调
如果差距 < 1.3x → rubric 基本 OK,差异在噪声范围
```
校准假设是 rubric 升级的种子——单条假设被 ≥3 样本验证 → 进入 bump 候选。
**校准样本不够时**:写"无可对照样本——仍写下我对这次的核心赌注(即使没有锚点)",然后写一两条这次想测的事。**不要删掉这段**。
---
### 组件 ∞:复盘段(Retrospective)— 仅追加
发布后 T+N 天复盘时追加。**不修改预测段任何字符**。
```markdown
## 复盘
**复盘时间**: 2026-05-07(发布 T+3d
**抓取时间**: 2026-05-07 09:30
**数据来源**: manual paste / adapter:douyin-session
### 实绩数据
- 播放:71.1w(落在 `30-100w` 桶内偏高,相对中枢 50w **+42%**
- 点赞:2.4w(赞播比 3.38%
- 分享:1.8w(分播比 2.53%,强)
### Top 评论关键词
- 「她不一样」模因爆发:2266 赞独占榜首,全文 12+ 次变体
### 哪些预测被验证 / 推翻
**被验证 ✅**:
- 关键校准假设完全成立:本篇 71.1w / 谁问你了 11.7w = 6.07x
- ER=5 主导情感传播力 → H1 强证据
**被推翻 ❌**:
- 中枢 50w 被超出 +42%
- 我对 SR 的押注反向被推翻:SR 在情感向场景应下调
### 需要写进 rubric-memo.md 的新观察
1. ER 在情感向场景的真实权重应 ≥ ×2.0
2. 议题分享冲动 (TS) 是隐藏维度
```
---
## 完整结构总览
```
file: predictions/YYYY-MM-DD_<id>_<short>.md
# 标题 — 预测日志 ← 组件 1: header(含 confidence + script_hash + Prediction Basis + BlindScored By + BlindScore Disagreement
metadata block
## 输入快照 ← 组件 2
scores + 用户改写要点 vs Claude 草稿)
## 预测 v1 ← 组件 3 ⭐ IMMUTABLE 起点(基于 pre-shoot 草稿)
bucket + 概率 + 中枢 + 一句话 reason
## 推理因素 ← 组件 4
(带方向 + 置信度的表)
## 锚点对比 ← 组件 5(校准池不够时仍写"N/A 段"
## 反事实场景 ← 组件 6
(每 bucket 一段"意味着什么"
## 关键校准假设 ← 组件 7
(这次预测作为实验的明确赌注)
## 预测 v2 (replaces v1) ← (可选) 拍后改稿 ≥30% 时由 cheat-shoot 触发,append 不覆盖
(同 7 组件结构 + 头部含 Diff vs v1 摘要)
## 复盘 ← 仅追加,IMMUTABLE 边界
(实绩 + top 评论 + 验证/推翻 + 新观察)
```
### v1 / v2 段约定
- **新建文件**cheat-predict 写 `## 预测 v1`(不再裸 `## 预测`——为 v2 留 schema 一致性)
- **legacy 兼容**v0.1.0 时期写的 `## 预测` 文件不动;hook 与 cheat-retro 都识别
- **v2 触发条件**cheat-shoot 检测拍摄稿与 `scripts/<id>.md` 的 line-diff ≥ 30%[V2_TRIGGER_THRESHOLD](../skills/cheat-shoot/SKILL.md)),调用 `/cheat-predict — mode: v2 — prediction-file: <path>`
- **append 而非覆盖**v2 段插在 `## 复盘` 之前。v1 段**绝不**修改(hook 物理强制)
- **校准用谁**cheat-retro 读最后一个 `## 预测 vN` 算偏差;v1 留作历史档案
- **diff 学习**v1 vs v2 的字段差异(如 ER 4→5)就是用户改稿带来的判分变化,是 rubric 升级证据
### Prediction Basis 字段
prediction header 必含 `Prediction Basis`
- `pre_shoot`v1 默认,标准盲预测)
- `post_shoot_pre_publish`(v2,软盲预测——拍后改稿但发布前重判)
cheat-retro 用此字段在 score-curve / bump 校准时区分两条数据线,避免混样。
---
## 子 skill 验收标准
`cheat-predict` 写完一份预测后,必须自检 7 个组件齐全:
- 组件 5 / 7 在校准样本不足时仍写"N/A 段 + 解释"**不允许直接跳过**
- header 的 `Calibration Samples` + `Confidence` 必填——读者一眼看到这次预测多可信
`cheat-retro` 写复盘段时,必须先校验该文件的 7 个组件:
- 缺组件 → 警告"该 prediction 不规范,复盘价值打折"
- 复盘段格式与 confidence 等级**无关**——任何阶段复盘都是同一格式
- diff `Script Hash` 与当前 `videos/<id>/script.md` 的 hash → 不一致则在复盘段加 `**Script changed between predict and shoot**` 警告
---
## 与旧设计的对照(v1 用户迁移参考)
| v0 设计(已弃) | v1 设计(当前)|
|---|---|
| `prediction_complexity = cold-start-simple` 用 3 组件简化版 | 删除字段。所有预测都用 7 组件统一版 |
| `prediction_complexity = complete` 用 7 组件 | 同上——一直就是 7 组件 |
| 第 5 次复盘"解锁完整预测" | 不需要解锁——一直完整。Confidence 等级随 calibration_samples 自动提升 |
| Cold-start 期跳过 bucket / 锚点对比 / 反事实 | **不跳过**——锚点不够就显式标"N/A",bucket 该写还写(概率分布需诚实平摊) |
| 用 mode=cold-start 字段判断流程分支 | 删除字段。所有 skill 走同一流程,按 calibration_samples 渐进显示 confidence |
+346
View File
@@ -0,0 +1,346 @@
# State Management(状态文件读写约定)
被所有子 skill 引用。`.cheat-state.json` 是各子 skill 共享上下文的**单一来源**——任何运行时状态、累计指标、模式标记都从这里读、写回这里。
---
## 文件位置
```
<user-content-project>/.cheat-state.json
```
**绝不**放到全局 `~/.claude/` 或 cheat-on-content 自己的目录——一个用户可能维护多个内容项目,每个项目独立状态。
---
## 完整 schema
```json
{
"schema_version": "1.4",
"skill_version": "1.0.0",
"rubric_version": "v0",
"content_form": "opinion-video",
"typical_duration_seconds": 240,
"target_publish_cadence_days": 2,
"rubric_form_mismatch": false,
"benchmark_status": "none",
"benchmark_name": null,
"benchmark_sample_count": 0,
"baseline_plays": null,
"calibration_samples": 0,
"calibration_samples_at_last_bump": 0,
"data_collection": "manual",
"pool_status": "none",
"data_layer": "markdown",
"hooks_installed": false,
"enabled_trend_sources": ["manual-paste"],
"enabled_perf_adapters": [],
"last_bump_at": null,
"last_bump_self_audited": false,
"last_published_at": null,
"last_published_file": null,
"last_retro_at": null,
"last_trends_run_at": null,
"last_trends_added_count": 0,
"last_prediction_self_scored": false,
"last_self_scored_at": null,
"consecutive_directional_errors": [],
"pending_retros": [],
"shoots": [],
"in_progress_session": null,
"initialized_at": "2026-05-04T15:00:00+08:00"
}
```
### 关键变更(v1.4
相比 v1.3**MINOR but BREAKING for blind channel integrity**——老用户必须跑 migrate):
- **rubric 文件拆分**`rubric_notes.md``rubric_notes.md`(公式 + 通用维度定义;blind 白名单)+ `rubric-memo.md`(升级 Memo 含证据 + 派生证据;blind 硬禁读)
- **state 字段不变**——仅 `schema_version` bump 标识老用户须跑迁移把现有 rubric_notes.md 拆成两份文件
- 配合 [skills/cheat-score-blind/SKILL.md](../skills/cheat-score-blind/SKILL.md) 的 `blocked_rubric_memo` refusal_code + cheat-bump Phase 5 leak guard 自检
- **不跑 migrate 的后果**blind sub-agent 仍会读到 rubric_notes.md 里的实绩,sub-agent 会自报 `non_blind_warning` 并降所有 confidence 到 medium——可用但不再是"真盲"
- 详见 [migrations/1.3-to-1.4.md](../migrations/1.3-to-1.4.md)
### 关键变更(v1.3
相比 v1.2MINOR,兼容):
- **新增 `last_prediction_self_scored: bool`**——`true` 仅当上一次 `/cheat-predict` 走了 `--skip-blind` flag 或 Phase 2.5 用户选 b(信主 Claude 自估)。cheat-status / SessionStart hook 据此 nag"上次预测没走 blind sub-agent,已 N 天"
- **新增 `last_self_scored_at: ISO 8601 / null`**——`last_prediction_self_scored` 触发时的时间戳;走 sub-agent 时一起清回 null
- 配合 [skills/cheat-score-blind](../skills/cheat-score-blind/SKILL.md) 的 channel B 隔离协议——把 contamination 跟踪从"靠 git history"升级为"靠 state 字段"
- 老 state 缺这两字段 → 兜底 `false` / `null`**MINOR 兼容**
### 关键变更(v1.2
相比 v1.1MINOR,兼容):
- **`shoots[]` 项 schema 扩展**——新增 `scripts_path``script_consistency``script_diff_pct``v2_prediction_written``script_hash_at_shoot` 字段。语义见 cheat-shoot Phase 4。这些字段记录"拍后改稿是否触发 v2 预测重判"cheat-retro 据此决定读 `## 预测 v1` 还是 `## 预测 v2`
- 老 state 缺这些字段 → skills 用 `state.get(field, default)` 兜底(`script_consistency` 默认 `"consistent"``v2_prediction_written` 默认 `false``script_diff_pct` 默认 `null`)。**不强制跑 migrate**——但跑了让 state 字段对齐 schema 文档
### 关键变更(v1.1
相比 v1.0
- **删除 `mode`**"cold-start" / "calibration" 二元)→ 用 `calibration_samples` 整数判断状态
- **删除 `prediction_complexity`**"cold-start-simple" / "complete" 二元)→ 所有预测都用统一完整 7 组件结构,**confidence 等级派生自 calibration_samples**
- **删除 `bucket_scheme`**"ratio" / "absolute" / "absolute_with_ratio" / "percentile" 四档)→ bucket 边界由单一算法**自动派生**:有 `baseline_plays` → 按倍数;无 → 平台通用默认;样本 ≥10 → 重算 baseline
理由:硬模式切换是设计者的猜测,不是用户体验该有的样子。统一流程 + 渐进信心标注更符合"频道是不断进化的连续光谱,不是离散阶段"的事实。
---
## 字段说明(每个字段的语义 + 谁写谁读)
### 元数据
| 字段 | 类型 | 写入者 | 读取者 | 说明 |
|---|---|---|---|---|
| `schema_version` | string | cheat-init / cheat-migrate | 所有 skill | "1.1"。schema 升级时 bump;老用户由 [/cheat-migrate](../skills/cheat-migrate/SKILL.md) 升级。详见 [migration-protocol.md](migration-protocol.md) |
| `skill_version` | string | cheat-init | 所有 skill | cheat-on-content 当前版本 |
| `initialized_at` | ISO 8601 | cheat-init | cheat-status | 首次初始化时间,永不变 |
### 模式与配置
| 字段 | 类型 | 取值 | 写入者 | 读取者 |
|---|---|---|---|---|
| `rubric_version` | string | "v0" / "v1" / "v2" / ... | cheat-init / cheat-bump | cheat-score / cheat-predict / cheat-retro |
| `content_form` | enum | "opinion-video" / "long-essay" / "short-text" / "podcast" / "other" / "mixed" | cheat-init | cheat-predict / cheat-recommend |
| `typical_duration_seconds` | int | 用户视频典型时长。决定 cheat-seed 写 draft 的字数 + cheat-predict 锚点优先同时长 | cheat-init | cheat-seed / cheat-predict |
| `target_publish_cadence_days` | int / null | 用户目标发布频率(1=日更 / 2=隔日 / 7=周更 / null=灵活)。决定 buffer 警戒颜色阈值 | cheat-init | cheat-status / cheat-recommend / cheat-shoot / cheat-publish / SessionStart hook |
| `rubric_form_mismatch` | bool | true 表示 content_form ≠ opinion-video 但仍用 opinion 内置 rubric 起步——提示用户 bump 时调权重 | cheat-init | cheat-status(持续提示) |
| `benchmark_status` | enum | "none" / "imported" / "pending"(用户答应等下找)| cheat-init / cheat-learn-from | cheat-seedbrainstorm 时读 benchmark.md/ cheat-statuspending 时持续提醒) |
| `benchmark_name` | string / null | 对标账号名(如"蜗牛学长留学");none 时为 null | cheat-learn-from | cheat-status / cheat-seed |
| `benchmark_sample_count` | int | 已导入的对标视频条数 | cheat-learn-from(写入 / append | cheat-statusN≥10 时提示 benchmark 影响淡出) |
| `baseline_plays` | int / null | 用户基准播放数;首次 init 时若有抓取历史→中位数;无→null;后续 cheat-retro 第 1 篇有实绩时回填 | cheat-init / cheat-retro / cheat-bump (--bucket-only) | cheat-predict(派生 bucket 边界) |
| `data_collection` | enum | "manual" / "adapter" | cheat-init | cheat-retro(决定 DATA_SOURCE 默认值) |
| `pool_status` | enum | "none" / "markdown" / "notion" / "sqlite" | cheat-init / cheat-recommend | cheat-recommend / cheat-status |
| `data_layer` | enum | "markdown" / "sqlite" | cheat-init / md-to-sqlite.py | 所有读 predictions 的 skill |
| `hooks_installed` | bool | true / false | cheat-init | cheat-status(持续提示) |
| `enabled_trend_sources` | array of string | trend-source adapter 名列表(如 `["weibo-hot", "zhihu-hot"]` | cheat-init / 用户手动 | cheat-trends |
| `enabled_perf_adapters` | array of string | perf-data adapter 名列表(如 `["douyin-session"]`)。空 → cheat-retro 走 manual paste | cheat-init / 用户手动配置后 | cheat-retro |
### 累计计数
| 字段 | 类型 | 写入者 | 用途 |
|---|---|---|---|
| `calibration_samples` | int | cheat-retro(每次复盘 +1 | cheat-status 进度条 / cheat-bump 门槛 |
| `calibration_samples_at_last_bump` | int | cheat-bump | "距上次 bump 多少新样本" |
### 时间戳(last_X_at
| 字段 | 类型 | 写入者 |
|---|---|---|
| `last_bump_at` | ISO 8601 / null | cheat-bump |
| `last_bump_self_audited` | bool | cheat-bumpCROSS_MODEL_AUDIT=false 时 true |
| `last_published_at` | ISO 8601 / null | cheat-publish |
| `last_published_file` | string / null | cheat-publish |
| `last_retro_at` | ISO 8601 / null | cheat-retro |
| `last_trends_run_at` | ISO 8601 / null | cheat-trends |
| `last_trends_added_count` | int | cheat-trends |
| `last_prediction_self_scored` | bool | cheat-predict`--skip-blind` 或 Phase 2.5 选 b 时 true;下次走 sub-agent 时清回 false |
| `last_self_scored_at` | ISO 8601 / null | cheat-predict(跟随 `last_prediction_self_scored` 同步) |
### 列表队列
| 字段 | 类型 | 写入者 | 读取者 | 协议 |
|---|---|---|---|---|
| `consecutive_directional_errors` | array of "high"/"low" | cheat-retropush / cheat-bump(清空) | cheat-status / cheat-retro 自检 | 最近 N 次复盘的偏差方向;连续 3 同向触发 bump 提议 |
| `pending_retros` | array of file path | cheat-publishpush / cheat-retroremove | cheat-status | 等待复盘的预测文件路径 |
| `shoots` | array of {video_folder, prediction_file, shot_at, ad_hoc, scripts_path, script_consistency, script_diff_pct, v2_prediction_written, script_hash_at_shoot} | cheat-shootpush / cheat-publishremove | cheat-status / cheat-recommend / SessionStart hook | 已拍未发队列。`len(shoots) = buffer count``buffer_days = buffer × target_publish_cadence_days` 决定颜色。v1.2 新增字段语义见 cheat-shoot Phase 4 |
### 会话状态
| 字段 | 类型 | 写入者 | 读取者 | 协议 |
|---|---|---|---|---|
| `in_progress_session` | object / null | cheat-predict(创建) / cheat-publish(清除) | cheat-publish / cheat-status | 见下方"in_progress_session 子结构" |
#### `in_progress_session` 子结构
```json
{
"type": "prediction",
"file": "predictions/2026-05-04_a3f2c1d4e5b6_停止期待.md",
"started_at": "2026-05-04T14:00:00+08:00",
"rubric_version": "v2"
}
```
`type`:当前只有 `"prediction"`。未来可能加 `"bump"` 表示长流程 bump 在进行中。
---
## 读写协议
### 读(任何 skill
```python
# 伪代码
import json, os
state_path = os.path.join(os.getcwd(), ".cheat-state.json")
if not os.path.exists(state_path):
# 不存在 = 用户没初始化,路由到 /cheat-init
raise NeedsInitError()
with open(state_path) as f:
state = json.load(f)
# 检查 schema_version 兼容
LATEST_SCHEMA = "1.1" # see migrations/registry.md
if state.get("schema_version") != LATEST_SCHEMA:
# 不直接 raise — 提示用户跑 /cheat-migrate(非阻塞)
log_warning(f"schema 版本不匹配:state={state.get('schema_version')}, 期望={LATEST_SCHEMA}。建议跑 /cheat-migrate")
# MINOR mismatch 通常仍能继续;MAJOR 时部分字段读取可能 KeyError → 用 .get(field, default) 兜底
```
**关键纪律**
- 读完不立刻关心字段缺失——用 `state.get(field, default)` 容错。新版 skill 引入新字段时旧 state file 会缺该字段,应优雅默认而非崩溃
- **绝不**在内存里 mutate state 后忘记写回——下游 skill 读到的是磁盘版
### 写(任何 skill
```python
# 伪代码 — read-modify-write 模式
state = read_state()
state["calibration_samples"] += 1
state["last_retro_at"] = now_iso()
write_state(state)
def write_state(state):
state_path = os.path.join(os.getcwd(), ".cheat-state.json")
tmp_path = state_path + ".tmp"
with open(tmp_path, "w") as f:
json.dump(state, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, state_path) # atomic rename
```
**关键纪律**
- **原子写**:写到 .tmp → rename。避免半写损坏的 state file
- **永远 indent=2**:人类可读,便于用户手改 + git diff
- **ensure_ascii=False**:保留中文字符不转 \uXXXX
- **写完再继续后续操作**:避免下游 skill 读到旧值
### 并发模型
预期场景:**单用户 + 单 Claude Code 会话**。不做锁。
如果两个会话并行操作同一个项目(罕见且不推荐):可能出现写覆盖。**未来需要时**可加文件锁(`fcntl.flock`);当前不加,避免引入复杂度。
---
## 字段写入责任表(防止"谁该写这个字段"歧义)
| 字段 | 唯一写入者 | 何时写 |
|---|---|---|
| `rubric_version` | cheat-init / cheat-bump | init 写初值;bump 升版 |
| `baseline_plays` | cheat-init / cheat-retro / cheat-bump (--bucket-only) | init 时若有 adapter 抓回历史→中位数;无历史→null;retro 第 1 篇有实绩→回填;bump --bucket-only→重新计算 |
| `calibration_samples` | cheat-retro | 每次复盘成功落盘 +1 |
| `pending_retros` | cheat-publishpush/ cheat-retroremove | publish 时 push 本次;retro 完成时 remove |
| `consecutive_directional_errors` | cheat-retropush/ cheat-bump(清空) | retro 判定偏差方向时 pushbump 落地时清空 |
| `in_progress_session` | cheat-predict(创建)/ cheat-publish(清除) | predict 写完文件时创建;publish 登记时清除 |
| `last_bump_at` | cheat-bump | bump 落地时 |
**绝不允许**多个 skill 写同一字段——会导致状态语义破碎。如果未来需要新字段,先想好"谁是唯一写者"。
---
## state file 损坏 / 不一致的处理
| 症状 | 处理 |
|---|---|
| 文件不存在 | 提示"未初始化,请跑 /cheat-init"**不**自动创建 |
| JSON 解析失败 | 提示"state file 损坏:path/to/.cheat-state.json",建议手动修复或备份 + 重新 init |
| schema_version 不识别 | 提示版本号 + 建议跑 [/cheat-migrate](../skills/cheat-migrate/SKILL.md)。SessionStart hook 会自动检测并提示 |
| `pending_retros` 含已删除的文件 | cheat-status 检测时安静移除,不报错 |
| `in_progress_session` 文件已不存在 | cheat-status 检测到 → 询问用户是否清理 |
| `calibration_samples``predictions/` 实际复盘数不一致 | cheat-status 报告差异。临时手改 state 即可;持续不一致是 bug,应在下个 minor 版本里加入 cheat-migrate 的 reconciliation step |
---
## 与 git 的关系
`.cheat-state.json` **应该**被纳入 git
- ✅ 它是项目配置 + 累计指标的快照
- ✅ git history 提供状态演化的完整轨迹
- ✅ 多设备同步靠 git push/pull
- ❌ **不**含敏感信息(cookie / API key 应放 `.env``.cheat-secrets.json`,单独 gitignore
`.cheat-cache/` 目录**不应该**被纳入 git
-`usage.jsonl`meta-logging 钩子的本地日志)
-`trends-history.jsonl`trend 抓取的去重缓存)
- 也可能含 adapter 调试文件(如 `douyin-session-debug/`
- 这些是设备本地状态,跨设备同步无意义
`/cheat-init` 应自动在用户项目根追加(不覆盖)`.gitignore`
```
.cheat-cache/
.cheat-secrets.json
```
---
## 升级路径
完整哲学和 maintainer checklist 详见 [migration-protocol.md](migration-protocol.md)。简版:
未来 schema 变化时:
1. bump `schema_version`(如 "1.1" → "1.2"
2.`migrations/<old>-to-<new>.md`4 段:WHAT/WHY/HOW/Manual fallback
3.`migrations/registry.md``LATEST_SCHEMA` 标记位 + 版本链表
4. SessionStart hook 检测到不一致时自动提示用户跑 `/cheat-migrate`
5. **绝不**让 skill 静默兼容旧版 schema 的删字段或重命名——那会让"哪个版本下哪个字段是什么含义"成谜
新增字段(MINOR,不破坏兼容):
-`state.get(field, default)`
- 老 state file 自动获得 default
- **仍需 bump schema_version + 写 migrations 文件**——保证状态文件最终一致;但用户可以延迟跑 migrate
删除 / 重命名 / 改语义(MAJOR,破坏兼容):
- 必须 bump schema_version + 写迁移文件
- CHANGELOG 标 `BREAKING`
---
## 用户手改 state file 的边界
允许手改的字段:
- `enabled_trend_sources`(数组,决定 cheat-trends 用哪些源)
- `data_collection`(切换 manual ↔ adapter
**不**建议手改的字段(会破坏不变量):
- `calibration_samples` / `pending_retros` / `consecutive_directional_errors`(应通过 retro 流程更新)
- `rubric_version`(应通过 bump 流程更新)
- `in_progress_session`(应通过 predict/publish 流程更新)
如用户确实想重置:建议**删除整个 .cheat-state.json + 重跑 /cheat-init**——这比手改单字段安全。
---
## Confidence label 派生表(**单一真值**
被 cheat-predict / cheat-status / cheat-recommend / SessionStart hook 等共同使用。从 `calibration_samples` 派生,所有 skill 用同一逻辑:
| `calibration_samples` | confidence emoji + 标签 | 数值含义 | 用户该如何用 |
|---|---|---|---|
| 0 | 🔴 极低 | "占星级别,纯纪律训练" | 不要基于 composite 决定要不要发;写 prediction 是为了**采集数据**,不是为了**做决策** |
| 1-2 | 🟠 低 | "中枢 ±50%,方向感优于绝对数字" | 信"A 比 B 流量好"的方向,不信具体数字 |
| 3-5 | 🟡 偏低 | "中枢 ±40%,可作为参考之一" | bucket 排序可用,中枢点估计仍是猜测 |
| 6-10 | 🟢 中 | "中枢 ±25%,可参与决策" | 可作为"要不要发"的依据之一 |
| 11-20 | 🟢 较高 | "中枢 ±15%rubric 形态稳定" | 可信中枢估计 |
| 21+ | 🔵 高 | "中枢 ±10%,可数据驱动 bump" | 进入数据驱动阶段 — bump 用回归而非直觉 |
> 上表的 ±X% 是**经验值**(基于参考博主的真实校准曲线),不是数学严格保证。新人账号的真实 ±X% 要等自己跑出 score-curve.png 才能验证。
**不要用这个表来 gating 任何功能**——所有 skill 在所有 calibration_samples 下都跑相同流程,只是输出里**显示**当前 confidence 等级。这是新设计的核心原则。
+477
View File
@@ -0,0 +1,477 @@
---
name: cheat-bump
description: 提议并执行 rubric 或 bucket 升级。两种模式:**完整 rubric bump**(最高风险动作,5 步强制 + 跨模型审核)和 **--bucket-only 轻量重校**(只换 bucket 边界,不动 rubric 公式)。**Phase 2 强制走 cheat-score-blind sub-agent 给校准池重打分**——不接受 self-scored fallback。触发词:"升级 rubric"/"bump rubric"/"更新公式"/"我想加一个维度"/"调整权重"/"重校桶"/"recalibrate bucket"。
argument-hint: --propose "<...>" | --bucket-only [--scheme ratio|absolute|percentile]
allowed-tools: Bash(*), Read, Write, Edit, Glob, Grep, Skill, Task, mcp__llm-chat__chat
---
# /cheat-bump — Rubric / Bucket 升级
两种模式:
| 模式 | 触发 | 做什么 | 验证强度 |
|---|---|---|---|
| **完整 rubric bump** | `--propose "<新公式>"` | 改公式 / 维度 / 权重 | 5 步 + 跨模型审核(强制) |
| **bucket-only 重校** | `--bucket-only` | 只重新派生 bucket 边界 | 数据自动派生,无审核 |
完整 rubric bump 严格遵守 [shared-references/bump-validation-protocol.md](../../shared-references/bump-validation-protocol.md) 的 5 步。bucket-only 走轻量路径——见下方 Phase B。
## Overview
```
入口:用户触发 /cheat-bump
[Phase A0: 检测调用模式]
├─ --bucket-only → [Phase B: 轻量 bucket 重校]
└─ --propose → [Phase 0~6: 完整 rubric bump]
```
## Phase A0: 调用模式分流(先做)
读用户参数:
-`--bucket-only` → 走 **Phase B**(轻量重校)
-`--propose "<...>"` → 走 **Phase 0~8**(完整 rubric bump
- 都没有 → 询问用户:"你想做什么?1) 调 rubric 公式 / 加减维度 → --propose2) 只重新派生 bucket 边界 → --bucket-only"
如果用户说"我觉得 ER 太低了想调"→ 是 `--propose` 路径。
如果用户说"我账号长大了,bucket 不准了"→ 是 `--bucket-only` 路径。
**两条路径不能混调**——一次操作只做一种事。
---
## 完整 rubric bump 流程
```
[用户:升级 rubric --propose "ER×1.5→2.0,砍 NA,加 MS"]
[Phase 0: 前置门槛检查]
[Phase 1: 写出新公式完整方程]
[Phase 2: 校准池全量重打分]
[Phase 3: 计算排序一致性]
[Phase 4: 跨模型独立审核(强制)]
[Phase 5: 落地 + cleanup pass]
[Phase 6: 更新所有校准样本的 prediction 文件底部追加 Re-scored 行]
```
## Constants
- **READINESS_HEURISTIC** —
- **默认参考**:校准池 ≥ 5 样本 + 至少 1 个跨样本观察有 ≥3 样本支持
- **但 Claude 可以提议 bump**(即使样本少)如果观察信号特别强:
- N=3 但出现完全推翻当前 rubric 假设的强反例(composite 8.5 vs 实绩 5w 这种 ≥3x 偏差)
- 1 篇出现单点但极强的现象(如评论区出现 ≥2000 赞的单一模因)
- **Claude 也可以拒绝 bump**(即使样本足)如果证据弱:
- N=10 但观察都是低置信度的零碎 pattern,无清晰方向
- 用户复盘时大量"随便看了下"的非严肃判断
- **写在 prediction header 或 cheat-bump 输出时必说明**:本次提议是 default-aligned 还是 judgment-driven,给用户审视依据
- **THRESHOLD = 0.8** — 新排序与实绩排序一致性阈值(4/5)。这条**写死**——bump 验证的统计刚性
- **CROSS_MODEL_AUDIT = true** — 调外部 LLM 独立审核。false 仅用于离线
- **REQUIRE_CONFIRM = true** — 落地前要求用户明确"yes, bump"
## Inputs
| 必填 | 来源 |
|---|---|
| `--propose` 文本 | 用户参数;缺失则询问 |
| `rubric_notes.md` | 用户项目根 |
| `predictions/*.md` 全量 | 校准池数据 |
| `.cheat-state.json` | 状态 |
## Workflow
### Phase 0: 前置门槛检查
按 [bump-validation-protocol.md](../../shared-references/bump-validation-protocol.md) 的"何时禁止"段,逐项检查:
| 检查 | 失败处理 |
|---|---|
| 校准池总样本数 vs 观察强度 | **Claude 判断**——按 READINESS_HEURISTIC:默认 ≥5 样本但允许特例(强反例 / 强模因)。如不满足默认,Claude 必须**显式说明**为什么仍然提议 bump("虽然只 N=3 样本,但 X 这条出现 composite Y vs 实绩 Z,这是 W 倍偏差"),让用户审视 |
| 上次 bump 距今的新校准数 vs 观察成熟度 | **Claude 判断**——默认建议 ≥3 篇新样本,但如果连续 3 篇都强证据指向同一方向 → 不必再等 |
| `in_progress_session == null` | 拒绝:"你有 in-progress 预测未完成。先走完那条流程或清掉 state" |
| 触发条件成立(系统性偏差 / 跨样本新观察 / 新维度证据足) | 警告但不阻塞——询问用户为什么现在 bump |
通过 → 进入 Phase 1。
### Phase 1: 写出新公式完整方程
**不能只接受用户的简短描述**。把它展开为完整方程:
```
当前:v2 composite = (ER×1.5 + SR×1.5 + HP×1.5 + QL + NA + AB + SAT) / 8.5 × 2.0
提议:v2.1 composite = (ER×2.0 + HP×1.5 + MS×1.5 + QL + SR + TS + SAT) / 9.0 × 2.0
变化总结:
- ER ×1.5 → ×2.0(升)
- SR ×1.5 → ×1.0(降)
- 新增 MS ×1.5Memetic Shareability
- 新增 TS ×1.0Topic Shareability
- 删除 NA(与 HP 重叠)
- 删除 AB(被 TS 替代)
- 归一化常数 8.5 → 9.0
- 公式总维度数:7 → 7(净变化 0)
```
如果用户的提议含糊(如"ER 权重提一点")→ 询问具体数值,**禁止自己猜**。
### Phase 2: 校准池全量重打分(**强制走 blind sub-agent**
Glob `predictions/*.md` 中所有有完整复盘段的文件 → 校准池。
**bump 是工具最高风险动作——所有重打必须走 [cheat-score-blind](../cheat-score-blind/SKILL.md) sub-agent**。inline 重打 = 主 Claude 已经看过实绩,rank 一致性变成 overfit 而非真信号。
#### 强制约束
- **不接受 self-scored fallback**——`/cheat-predict``--skip-blind` flag,但 `/cheat-bump` **没有**。如果 Task tool 不可用 → **abort bump**,向用户报告"先解决 Task tool 再 bump"
- **不接受"我只重算 composite 不重打 dim"** —— 即使新公式只调权重不加维度,每条 prediction 的所有 dim 都要由 sub-agent 重新审 script。理由:旧 dim 分本身可能是污染的;权重变了不能保证旧 dim 还成立
#### 对每篇 prediction
1. 解析 prediction 文件拿到对应 `scripts/<id>.md` 路径(从 `Script Path` header 字段)
2. 校验 script 文件存在 + hash 跟 header `Script Hash` 一致;不一致 → 警告(script 改过了)但仍 spawn sub-agent
3. **通过 Task tool spawn cheat-score-blind sub-agent**
```
Spawn cheat-score-blind sub-agent.
Input:
script_path: <prediction header 的 Script Path>
rubric_notes_path: rubric_notes.md
sidecar_path: .cheat-cache/bump-rescores/<prediction-id>.json
Task: 按 rubric_notes 当前公式(已是新版 vN+1)给 script 打分。
返回严格 JSON。写 sidecar 文件用于 bump 主流程批量读取。
不要读 state file / predictions/ / videos/ 任何其他文件。
不要询问用户 —— 你没有用户。
不要读这份 prediction 文件本身 —— 你只看 script + rubric。
```
4. 等 sub-agent 完成 → 读 sidecar JSON → 主流程用新公式算 composite
5. 写"重打表"到 `.cheat-cache/bump-rescores.json`(汇总)。**每条 entry 标 `blind: true`** —— bump phase 5 cleanup 时把这个字段连同新分数写到 prediction 文件的 `Re-scored under v<N+1>` 行
#### 还污染没污染的诚实标注
即使走 sub-agent**仍有两类残余 contamination 要在 bump report 里诚实标注**
| 类型 | 来源 | 标注字段 |
|---|---|---|
| 模型 prior contamination | sub-agent 仍是 ClaudeRLHF 共享 | `model_prior_warning: true`(默认 true,不可关) |
| 用户自己 rubric design bias | rubric_notes.md 是用户写的,自然 fit 自己内容 | `rubric_self_designed: true`(默认 true,不可关) |
这两条提示用户 channel C(跨模型 audit)的不可省。bump 报告末尾必印:"上面的 rank 一致性是 channel A 内的一致性。**最终决策必须等 channel C audit 通过**。"
#### 失败模式
| 症状 | 处理 |
|---|---|
| 某条 prediction 的 script 文件不见了 | sub-agent skip 该条,主流程汇总报告"N 条因 script 缺失被排除"。如剩余有效池 < MIN_SAMPLES → abort bump |
| sub-agent 返回 `refusal != null` | 重发 Task 最多 3 次;仍败 → 该条标 `rescore_failed: true` 排除出校准池 |
| Task tool 整个不可用 | abort bump,提示用户"Task tool 是 bump 的硬依赖。如真的离线环境,跑 `/cheat-bump --bucket-only` 走轻量分支" |
| sub-agent 输出含 contamination_signal | 标 `suspicious: true` 但不排除——bump report 末尾列这些可疑条目让用户审 |
### Phase 3: 计算排序一致性
```
每个样本:
new_composite_rank: 用新公式排序的 rank
actual_plays_rank: 用实际播放排序的 rank
delta: |new_rank - actual_rank|
输出对照表:
| 样本 | composite (v2) | composite (v2.1) | rank (new) | actual | rank (actual) | delta |
|---|---|---|---|---|---|---|
| 仓鼠 | 9.41 | 9.55 | 1 | 124.8w | 1 | 0 |
| 停止期待 | 8.24 | 9.11 | 2 | 71.1w | 2 | 0 |
| 老板废话 | 7.65 | 8.11 | 4 | 39.6w | 3 | 1 |
| 求职悖论 | 8.47 | 7.56 | 5 | 16.8w | 4 | 1 |
| 谁问你了 | 8.24 | 7.00 | 6 | 11.7w | 5 | 1 |
排序一致性:4/5 在 |delta| ≤ 1
Pairwise no-regression:旧公式做对的所有 pair 在新公式下未颠倒 ✓
```
判定:
- 排序一致性 < THRESHOLD(默认 0.8 → **本地拒绝**,转 Phase 4 之前明确报告失败
- pairwise 出现回归 → **本地拒绝**
`THRESHOLD` 写死在协议里——不允许临时调低(那本身是另一个需要 bump 的元决策)。
### Phase 4: 跨模型独立审核(**强制**,除非 escape hatch
`CROSS_MODEL_AUDIT=true`(默认):
调用 `mcp__llm-chat__chat`
```
prompt:
你是一个独立审稿人。下面是一个内容创作者准备升级的 rubric 公式。
请独立判定两件事:
1. 排序一致性:新公式给样本的排序与实际表现排序,是否真的在 ≥80% 样本上一致?
2. 解释力:新公式相比旧公式,是否更好地解释了校准池的实绩分布?
数据:
旧公式:(ER×1.5 + SR×1.5 + HP×1.5 + QL + NA + AB + SAT) / 8.5 × 2.0
新公式:(ER×2.0 + HP×1.5 + MS×1.5 + QL + SR + TS + SAT) / 9.0 × 2.0
校准池:
[Phase 2 重打表的完整 JSON]
排序对照:
[Phase 3 表格的完整 JSON]
输出格式:
- 判定:PASS 或 REJECT
- 理由:≥100 字
- 关键风险:[如有,列出新公式的潜在问题]
```
收到外部 LLM 回复 → 解析判定。
判定逻辑:
- 本地 PASS + 外部 PASS → 通过,进入 Phase 5
- 本地 PASS + 外部 REJECT → **视为 REJECT**。冲突意味着至少一方解读不稳定
- 本地 REJECT → 已在 Phase 3 终止
- mcp__llm-chat__chat 不可用 → 优雅降级到 `CROSS_MODEL_AUDIT=false`state file 标 `last_bump_self_audited: true`
`CROSS_MODEL_AUDIT=false`
- 仅依赖本地判定
- state file 持续标记,cheat-status 持续提示用户"这次 bump 是自审,建议配置 mcp__llm-chat__chat"
### Phase 5: 落地 + cleanup pass
通过审核后,**REQUIRE_CONFIRM=true** → 询问用户:"新公式 PASS 本地与外部审核。最后确认:执行 bump 落地?这会修改 rubric_notes.md + rubric-memo.md 并删除若干已被吸收的观察。回答 'yes, bump' 才执行。"
用户确认后:
#### 5a. 更新 `rubric_notes.md`(**只放通用语言,不含视频名 / 实绩**)
- 顶部 metadata 更新:
- `**当前版本**: vN+1`
- `**Last bumped at**: <ISO 8601>`
- `**Upgrade memos**: 见 [rubric-memo.md](rubric-memo.md)`(指针,不复制 Memo 内容)
- 版本速查表加一行(只含版本号 + 公式签名,**不含**证据样本)
- 更新"当前评分维度"段(删 NA / AB,加 MS / TS
- **派生证据段** 如新维度需要锚点解释 → **用通用语言**:
- ✅ 允许:「派生证据:高抽象密度样本 → CC=1 → 低 reach」
- ❌ 禁止:「派生证据:「停止期待」CC=1 → 实绩 13.7w」(视频名 + 实绩 数字)
- 命中违禁 pattern → 把该段抽到 rubric-memo.md 的"派生证据"子段,原位用通用语言替代
#### 5b. 写 Memo 到 `rubric-memo.md`**append 模式,不覆盖历史**
按 [bump-validation-protocol.md](../../shared-references/bump-validation-protocol.md) Step 5 + [templates/rubric-memo.template.md](../../templates/rubric-memo.template.md) 格式 append 一段 Memo 到文件末尾:
- 触发观察(含真实观察 ID
- 证据数据(**校准池重打表 + 排序对照,含真实视频名 + 实绩**)
- 派生证据(**含真实样本名 + 实绩**)
- 诊断
- 新公式
- 跨模型审核结论引用(含模型名 + 判定 + 理由摘录)
- 已知局限
**绝不**覆盖 rubric-memo.md 已有内容——bump memo 按时间顺序累积。
#### 5c. cleanup pass(按 [observation-lifecycle.md](../../shared-references/observation-lifecycle.md) 的"cleanup pass 强制时机"
在 `rubric_notes.md` 内执行(**不**动 rubric-memo.md):
- 已被吸收为新维度的观察 → 删(如观察 E 被吸收为 MS → 删观察 E)
- 被新数据推翻的观察 → 删
- 仍未解决的观察 → 迁移到新版本"待验证假设"段
- 已被验证的"规律"→ 移到"规律沉淀区"
#### 5d. 整理 + 自检
- 重新读 `rubric_notes.md` 全文,确保读者能在 60 秒内理解当下规则——超出 600 行触发额外清算
- **自检 leak guard**:对 `rubric_notes.md` 跑 `grep -E '\\d+\\s*[wWmMkK万]|播放|实绩|实际'` → 如有命中 → **abort bump + 回滚**,提示用户"rubric_notes.md 写入了违禁内容(实绩 / 播放数)"。这些内容应在 rubric-memo.md,不在 rubric_notes.md
### Phase 6: 校准样本批量更新
对每个校准样本的 prediction 文件,**底部追加**(不动预测段、不动复盘段):
```markdown
---
**Re-scored under v2.1 on 2026-05-04**: composite=8.24 → 9.11 (blind: true)
rubric bump 时全量重算,由 cheat-score-blind sub-agent 独立打分;详见 rubric-memo.md 的 v2 → v2.1 升级 Memo
```
`blind: true` 字段**必填**——告诉未来读这条记录的人"这是 channel B 隔离打分,不是主 Claude 自评"。如果某条 prediction 在 Phase 2 因 sub-agent 失败被排除 → 不会有 Re-scored 行(保持原样)。
用 Edit 工具,匹配每个文件的最末尾。
### Phase 7: 更新 state file
```json
{
"rubric_version": "v2.1",
"last_bump_at": "<ISO timestamp>",
"last_bump_self_audited": false,
"consecutive_directional_errors": [],
"calibration_samples_at_last_bump": <current value>
}
```
清空 `consecutive_directional_errors`——新 rubric 重新计数。
### Phase 8: 控制台报告
```
✅ Rubric 已升级 v2 → v2.1
变化:
- ER ×1.5 → ×2.0
- SR ×1.5 → ×1.0
- 新增 MS / TS
- 删除 NA / AB
校准池重打:5/5 通过排序检查(4/5 一致 + 0 pairwise 回归)
跨模型审核:✅ PASS
Cleanup pass:删除观察 D 和 E(已吸收为 QL 重定义和 MS 维度)
下一篇预测起按 v2.1 公式打分。
所有历史预测文件已追加 Re-scored 标记。
```
---
## Phase Bbucket-only 重校(轻量分支)
`/cheat-bump --bucket-only [--scheme ratio|absolute|percentile]`
**与完整 bump 的本质区别**:bucket 边界不是规则的一部分,是数据派生量。重新派生它**不需要跨模型审核**——派生算法是确定性的,没有"判断"成分。
### B1: 选择算法(按可用样本数自动派生,**state 不存 scheme**
| 算法 | 适用 | 边界派生方式 |
|---|---|---|
| `ratio`(默认 N=1-4) | 小样本 | 上一篇 / 最近 3 篇中位数 × {0.3 / 1 / 3 / 10 / 30} |
| `absolute`(默认 N=5-9)| 中等样本 | 校准池中位数 × {0.3 / 1 / 3 / 10 / 30},固定边界 |
| `percentile`(默认 N≥10)| 大样本 | 校准池实绩 percentile {30 / 60 / 85 / 95 / 100} |
`--scheme` 参数允许用户**显式覆盖默认**:
- `--scheme ratio` 强制用 ratio(即使 N≥5
- `--scheme absolute` 强制用 absolute
- `--scheme percentile` 强制用 percentile(要求 N≥3,否则报错)
未指定 `--scheme` → 按上表自动派生。
> 旧设计有 `bucket_scheme` state 字段——v1.1 删了。所有 skill 实时按 calibration_samples 派生算法,不需要持久化"当前用哪个"。这避免了"切换 scheme 后忘了同步"的状态不一致问题。
### B2: 派生新边界
读 `predictions/*.md` 中所有有 `actual_plays` 的样本。
**ratio 模式**
```
baseline = median(最近 3 篇 actual_plays)
buckets = {
"退步": (-inf, baseline * 0.3),
"持平": (baseline * 0.3, baseline * 1),
"命中": (baseline * 1, baseline * 3),
"小爆": (baseline * 3, baseline * 10),
"大爆": (baseline * 10, +inf),
}
```
**absolute 模式**
```
baseline = median(全部校准池 actual_plays)
buckets = {
"底部": (-inf, baseline * 0.3),
"基础盘": (baseline * 0.3, baseline * 1),
"命中": (baseline * 1, baseline * 3),
"爆款": (baseline * 3, baseline * 10),
"现象级": (baseline * 10, +inf),
}
```
**percentile 模式**
```
sorted_plays = sorted(全部校准池 actual_plays)
buckets = {
"底部": ≤ p30,
"基础盘": p30 - p60,
"命中": p60 - p85,
"小爆": p85 - p95,
"大爆": ≥ p95,
}
```
### B3: 报告变化 + 用户确认
```
当前 bucket scheme: ratio
proposed scheme: absolute
baseline: 4.2w 中位数(基于 5 篇校准样本)
新边界:
- 底部: < 1.3w
- 基础盘: 1.3w - 4.2w
- 命中: 4.2w - 12.6w
- 爆款: 12.6w - 42w
- 现象级: > 42w
派生说明:
- 5 篇实绩:1.5w / 3.8w / 4.2w / 5.6w / 18w
- 中位数 4.2w,新桶按 ×{0.3, 1, 3, 10} 派生
确认应用?(yes / no)
```
### B4: 落地
用户确认后:
1. 编辑 `rubric_notes.md` 的 "Bucket 方案" 段,替换为新表
2. 更新 `.cheat-state.json` 的 `baseline_plays` 字段(bucket scheme 不持久化——下次 cheat-predict 实时派生)
3. 在 `rubric_notes.md` 的 bucket 段顶部追加一行变更记录:`v2 buckets recalibrated on YYYY-MM-DD: scheme=absolute, baseline=4.2w (基于 N=10 个样本)`
4. **不**修改任何 prediction 文件——历史预测的 bucket 标签保持原样(在该样本写入时的方案下做出的判断)
### B5: 对未来预测的影响
下一次 `/cheat-predict` 起按新 bucket 派生。历史 prediction 文件里的 bucket 标签**不重算**——bucket 是预测时的语义判断,事后改写会破坏盲度。
### Phase B 不做的事
- 不重打 composite(公式没变)
- 不重新审核观察段(rubric 没变)
- 不调跨模型审核(确定性派生无需判断)
- 不要求严格的样本数门槛(按 READINESS_HEURISTIC 由 Claude 判断;ratio 模式 N=1 就能跑)
---
## Key Rules
1. **5 步不可跳**(仅完整 rubric bump)。任何"先简化跑一下"的请求都拒绝
2. **THRESHOLD 写死**(仅完整 rubric bump)。不允许动态调整
3. **跨模型审核是默认**(仅完整 rubric bump)。关闭审核需要在 state file 显式标记
4. **cleanup pass 是 bump 的一部分**(仅完整 rubric bump)。不允许 bump 完不清理观察段
5. **REQUIRE_CONFIRM**(两种模式都要)。最后落地前必须用户明确说 "yes, bump" 或 "yes, recalibrate"
6. **bucket 重校不动历史预测**。bucket 是预测时语义,事后改写破坏盲度
## Refusals
- 「跳过校准池重打,直接换公式」 → 拒绝。原则 #2
- 「跳过 cheat-score-blind sub-agent,主 Claude 直接重打就行」 → 拒绝。bump **不接受**任何 self-scored fallback——sub-agent 不可用 → abort bump,不接受"自审"
- 「跳过外部 LLM 审核」 → 仅当 `CROSS_MODEL_AUDIT=false` 显式设置
- 「这次 THRESHOLD 调到 3/5 让它过」 → 拒绝。改 THRESHOLD 是元层级 bump
- 「保留所有旧观察作为历史」 → 违反原则 #3
- 「先 bump,cleanup 下次再做」 → 拒绝。cleanup 是 bump 的一部分
- 「只重算 composite 不重打 dim」 → 拒绝。新权重 × 旧 dim 仍是旧污染。每个 dim 都由 sub-agent 重审 script
- 「把 Memo 全文写进 rubric_notes.md 顶部,方便我读」 → 拒绝。rubric_notes.md 是 blind sub-agent 白名单——含视频名 / 实绩 → 通过白名单泄漏。Memo 写 rubric-memo.md(白名单**外**),rubric_notes.md 只放公式 + 通用语言维度定义 + 指针
- 「派生证据段保留真实视频名,让 rubric 读起来更具体」 → 拒绝。在 rubric_notes.md 必须用通用语言("高抽象密度样本");带视频名的派生证据写 rubric-memo.md
## Integration
- 上游:`/cheat-retro` 检测到 ≥3 同向偏差 → 提议跑 `/cheat-bump`
- 依赖:`mcp__llm-chat__chat`(如配置)+ Task toolspawn cheat-score-blind
- 修改:
- `rubric_notes.md`(结构性更新,**绝不**写真实视频名 / 实绩)
- `rubric-memo.md`**新**——append Memo 全文,含证据 + 派生证据)
- 所有 `predictions/*.md`(追加 Re-scored 行,不动预测段)
- `.cheat-state.json`
- 下游:下一篇 `/cheat-predict` 自动按新 rubric_version 打分
+535
View File
@@ -0,0 +1,535 @@
---
name: cheat-init
description: cheat-on-content 的首次 onboarding 与脚手架创建器。统一流程——所有用户都走相同 5 阶段闭环,唯一区别是"发过视频的人"会在 init 时多一步:抓取已有视频建立历史 context(用于后续 cheat-seed 给更贴合的选题、更准的 baseline)。触发词:"初始化"/"init"/"首次使用"/"我是新用户"/"setup cheat-on-content"。**必须在用户第一次会话执行;其他子 skill 在 .cheat-state.json 不存在时自动路由到此。**
argument-hint: [— form: opinion-video|long-essay|short-text|podcast]
allowed-tools: Bash(*), Read, Write, Edit, Glob, WebFetch, Skill
---
# /cheat-init — 首次 onboarding
让用户从零到能跑第一篇预测,全程 ≤ 5 分钟(没发过历史的)或 ≤ 10 分钟(已发过、要 import 历史的)。
## Overview
```
[用户首次说"初始化"]
[Phase 0: 检测当前状态]
[Phase 1: 首屏文案 — 适用性 + 期望管理]
[Phase 2: 6 个问题(Q1-Q5 都问;Q2 决定是否走 user-history import]
[Phase 2.5: 对标账号 — 强烈建议(cold-start 必须问,已发用户可选)]
[Phase 3: 创建脚手架(含 scripts/ + videos/ + samples/ 空目录 + 模板文件含 benchmark.md]
[Phase 3.5: user-history import 流程(仅 Q2=有发过历史 + 用户同意)]
[Phase 4: 测试 hook 是否生效]
[Phase 5: 给"下一步该说什么"清单]
```
## Constants
- **DEFAULT_RETRO_WINDOW_DAYS = 3**
- **INSTALL_HOOKS = ask** — 默认询问;用户选 `auto` 直接装;`skip` 不装
- **TREND_DEFAULT_SOURCES = ["manual-paste"]**
## Inputs
无。所有信息从 6 个对话问题里收集。
## Workflow
### Phase 0: 检测当前状态
1. 读用户当前工作目录(**用户的 content project,不是 cheat-on-content 自己**
2. 检查是否已存在 `.cheat-state.json`
- 存在 → 提示"项目似乎已初始化(state file 存在)。要重新初始化会覆盖现有配置——确认?" 等用户明确确认才继续
- 不存在 → 进入 Phase 1
3. 检查是否已存在 `rubric_notes.md` / `predictions/` 等核心文件——存在但 state file 不存在 → 是"半初始化"状态,提示用户并询问"要从现有文件推断状态还是重置?"
### Phase 1: 首屏直白告知期望(含适用性验证)
向用户输出(一字不漏,不要软化):
```
🎯 Cheat on Content / 网红外挂 — 初始化
你的下一条内容已经在改写 3 个月后的你。
规律是客观存在的,区别是你**看见**还是**没看见**。
这套让你看见。
接下来 5-10 分钟我会问你 5-6 个问题搞清楚你做什么、有什么、怎么用。
两件事先说在前面:
1. **早期预测会不准**——前 5 篇精度大概 ±50%,这是数学事实。
工具用 🔴🟠🟡🟢🔵 标 confidence 等级,不藏数字——
你自己判断这次能不能信。
2. **强烈建议导对标账号**——5-10 条对标视频,工具立刻有 anchor。
不然第一批预测基本是占星。后面 Q5 会再问一次。
准备好开始吗?
```
如果用户答"继续"或类似肯定回应 → Phase 2。
不再因为 content_form 拒绝继续——任何形态都允许,只是 `rubric_form_mismatch` 字段标真,cheat-status 后续会持续提示用户"你的形态需要 bump 调权重"。
### Phase 2: 6 个问题(一问一答,**不**批量提问)
**Q1: 内容形态**
> "你的内容更接近哪一种?
> a) **观点视频**(评论 / 时评 / 论说 / 议题讨论 / 个人观点)— 直接匹配内置 rubric
> b) **长文 essay**(公众号 / Substack / Medium)— 可借观点视频 rubric 起步,bump 时调权重
> c) **短文 / thread**X / 微博 / 即刻)— 同上
> d) **播客 / 视频长内容**(YouTube 长片 / 播客)— 同上
> e) **教程 / 工具教学 / Builder**(教别人怎么用 X 工具 / 怎么做 Y 项目)— 同上
> f) **其他**(游戏 / 美食 / 妆教 / 新闻 / 剧情)— 工作流通用,但 rubric 维度需要调
> ER / SR / HP 这套对你形态可能不太预测,需要自己拆出适合的维度)
> g) **混合**"
记录到 `content_form` + `rubric_form_mismatch`
**Q1 → `content_form` enum 映射**(**必须存 enum 值,不是字母**):
| 用户答 | `content_form` 写入值 |
|---|---|
| a | `"opinion-video"` |
| b | `"long-essay"` |
| c | `"short-text"` |
| d | `"podcast"` |
| e | `"tutorial-builder"` |
| f | `"other"` |
| g | `"mixed"` |
`rubric_form_mismatch` 派生:
- 选 a → `false`
- 选 b/c/d/e/f/g → `true`cheat-status 持续提示"你的形态可能需要 bump 调权重"
- **不再有"严重不匹配"档**——所有形态都能跑工作流,只是有的 rubric 需要更激进的 bump
**Q1.5: 典型时长**(仅 Q1=a/d/f 时问)
> "你的视频典型时长?
> a) 30秒-1分钟 b) 1-3分钟 c) 3-5分钟(推荐起步)
> d) 5-10分钟 e) 10分钟以上"
记录到 `typical_duration_seconds`30 / 90 / 240 / 450 / 900)。
**Q1.6: 发布频率**
> "你打算多久发一篇?
> a) 日更 b) 隔日 c) 每周 d) 灵活 / 不固定(关闭 buffer 监控)"
记录到 `target_publish_cadence_days`1 / 2 / 7 / null)。
**Q2: 你这个频道发过视频吗?**
> "a) 没发过 — 我会帮你从兴趣 + 热点 brainstorm 5 个候选 + 写 5 份初稿
> b) 发过 — 不管 1 条还是 100 条,我会帮你抓历史让后续 brainstorm 更贴合你做过什么"
如选 a → state 写 `calibration_samples: 0`**Phase 3.5 跳过**,直接进入 Phase 4。
如选 b → 进入 **Q2.1**
**Q2.1: 平台 + 抓取计划**(仅 Q2=b
> "你内容主要在哪个平台?
> a) 抖音 — 装 douyin-session adapterPlaywright + 扫码登录抖音创作者中心)
> b) 小红书 — 装 xhs-explore adapterPlaywright + 扫码登录小红书创作者中心)
> c) YouTube — 装 youtube-data-api adapter(需 API key
> d) B 站 — bilibili-stat adapter
> e) LinkedIn — 装 linkedin-session adapterPlaywright + 登录 LinkedIn,抓单帖分析)
> f) 其他 / 多平台 — 走 manual paste 模式"
如选 a/b/c/d/e → 询问 Q2.2;如选 f → 跳到 Q2.3 manual。
**Q2.2: adapter 安装时机**(仅 Q2.1=a/b/c/d/e
> "现在装 adapter 自动抓取,还是先手动告诉我?
> - 现在装 — 引导你装 Playwright + 扫码 → 抓回最近 N 条数据
> - 等下再装 — 先 manual 模式,state 标 'pending_adapter_setup'
> cheat-status 持续提示装"
如选"现在装"→ 走 adapter install 引导(详见各 adapter README)→ 验证抓取可用 → Q2.3。
如选"等下"→ 跳到 Q2.3 manual。
**Q2.3: 抓取范围 / 历史规模**
如 adapter 已装并验证可用:
> "我可以抓你最近多少条作为基础?
> (建议 10-25 条;样本越多 baseline 越准。最多到你账号实际数量)"
→ 用户给数字 NPhase 3.5 抓取 N 条
如 manual 模式:
> "你大概发过多少条?给个范围就行(比如 '5-10 条' / '20+ 条'),
> 这只用来标 calibration_samples 估值,不用准确。"
→ 用户给一个估值,Phase 3.5 跳过抓取,calibration_samples 写估值
**Q3: 数据回收方式**
> "T+3 天复盘怎么拿数据?
>
> a) 手动粘 — 候补方案。**你必须粘 top 20+ 评论(带赞数)**,不是只粘播放数。
> 评论才是真信号——'她不一样'这种模因爆发只能从评论看出,
> 播放数永远告诉不了你什么内容真的击中了观众。
> b) **[推荐默认]** adapter 自动抓 — 评论 + 数据全要。
> 如果你现在没装 adapter,没关系,state 标 'pending_adapter_setup'
> 第一次 publish 之前装上就行(cheat-status 会持续提醒装)。
> 装的指引在 adapters/perf-data/<platform>/README.md。"
**Q3 → `data_collection` enum 映射**
| 用户答 | `data_collection` 写入值 |
|---|---|
| a | `"manual"` |
| b(默认) | `"adapter"` |
默认推荐 b——除非用户明确说 "a 我就要手动"。
**Q4: 候选选题**
> "你现在有候选选题列表吗?(如有外部 markdown / Notion 维护的)
> a) 没有(默认)— 一会儿我帮你 brainstorm,或日常用 /cheat-trends 抓
> b) 有,markdown 列表
> c) 有,Notion / 其他"
**Q4 → `pool_status` enum 映射**
| 用户答 | `pool_status` 写入值 |
|---|---|
| a(默认) | `"none"` |
| b | `"markdown"` |
| c | `"notion"` |
**Q5: 装几个 hook(默认装,不需要你决定)**
> "Q5:我顺便装几个 hook,回 'yes' 或 'enter' 就装:
>
> 1. **预测锁** — 我们一起做完预测后,文件被锁。你或我都不能改预测段。
> 复盘只能往同一文件下半段追加,不污染上半段判断。
> (没这个锁,事后看到数据想"修一下当时的预测"几乎是必然的——你或我都会犯)
>
> 2. **SessionStart 自动报告** — 每次开新会话顶部显示 buffer / 待复盘 / 候选 top
>
> 3. **静默使用日志** — 异步记录使用频率,不阻塞,给将来诊断用
>
> 三个一起装。**不装也可以**(回 'no')但你失去预测锁,校准价值会下降。
>
> 回 yes / no。"
**Q5 → `hooks_installed` 映射**
| 用户答 | `hooks_installed` 写入值 |
|---|---|
| yes / enter / 默认 | `true`bool**不是字符串 `"yes"`** |
| no | `false` |
默认 yes——除非用户明确说 no。
### Phase 2.5: 对标账号(**所有用户都问**cold-start 强烈建议)
> 工具早期最重要的信号源是**对标账号**——你 init 完没数据,rubric 等权 v0 等于占星。
> 但如果你能找一个你想做成那样的账号,导入 5-10 条它的高 / 中 / 低样本,工具就有了 anchor。
询问:
```
🎯 对标账号
你能找一个对标账号吗?至少 3 条该账号的视频。
- 你**完全没发过历史**(Q2=a)→ **强烈建议**——rubric 没 anchor 全靠对标。
不找的话用通用 v0 等权 rubric,前 5 篇精度更差更久
- 你**已发历史**(Q2=b)→ **可选**——你也可以只用自己历史 calibrate;
但建议至少导 1 个对标做 sanity check(看你账号是否真的偏离对标方向)
a) 现在找 → 立刻进入 /cheat-learn-from5-15 分钟,看你材料准备程度)
b) 等下找 → state 标 `benchmark_status: pending`cheat-status 持续提醒
c) 不找 → state 标 `benchmark_status: none`,用通用 v0 起步
回 a / b / c。
```
行为:
- 选 a → Phase 3 创建脚手架完毕后,**自动 dispatch 到 /cheat-learn-from**(不让用户手动跑——已经在 init 流程里了)。完成后回 init Phase 4
- 选 b → state 标 `benchmark_status: pending` + `benchmark_name: null`
- 选 c → state 标 `benchmark_status: none`
记录到 `benchmark_status` / `benchmark_name`(如 a 选则在 cheat-learn-from 里写入)。
### Phase 3: 创建脚手架(逐项解释)
按顺序创建并**解释每一项的作用**:
0. **`.gitignore`(安全 — 必须第一步创建)**
```
"先创建 .gitignore,把账号凭证挡在版本控制外——这是第一件事。
.auth/ / .auth-xhs/ / .auth-linkedin/ 存的是抖音 / 小红书 / LinkedIn 的登录态(等同账号密码),
.cheat-secrets.json 存 API key / cookie——一旦被 commit 或云同步就等于泄露账号。
注意:predictions/ videos/ scripts/ 这些**不**忽略——原则 #1/#3 依赖
git history 作为预测的不可变档案,必须入库。"
```
- 复制 `cheat-on-content/templates/gitignore.template` → `<user-repo>/.gitignore`
- 如 `<user-repo>/.gitignore` **已存在** → 不覆盖;逐行检查并**追加缺失行**,至少确保
`.auth/`、`.auth-xhs/`、`.auth-linkedin/`、`.cheat-secrets.json` 四行存在
- 即使用户项目当前**还不是 git 仓库**也照常创建——它会在用户 `git init` 的那一刻立即生效
- 创建后提醒一句:如果项目已经 `git init` 过且可能误加过 `.auth/`,让用户跑
`git rm -r --cached .auth .auth-xhs .auth-linkedin .cheat-secrets.json` 把已暂存的凭证移出
1. **`.cheat-state.json`**
```
"正在创建 .cheat-state.json — 各子 skill 共享上下文的地方。
这次 init 收集的所有答案都会写在这里。"
```
写入(**所有 `<...>` 占位必须查上面 Q 的映射表换成具体 enum 值,绝不直接存字母**):
```json
{
"schema_version": "1.4",
"skill_version": "1.0.0",
"rubric_version": "v0",
"content_form": "<查 Q1 映射表,写 enum 字符串如 \"opinion-video\">",
"typical_duration_seconds": <Q1.5 派生:30/90/240/450/900>,
"target_publish_cadence_days": <Q1.6 派生:1/2/7/null>,
"rubric_form_mismatch": <Q1=a→false;其他→true>,
"benchmark_status": "<Phase 2.5 派生:a→\"imported\"/b→\"pending\"/c→\"none\">",
"benchmark_name": <imported 则字符串名,否则 null>,
"benchmark_sample_count": <imported 则数字,否则 0>,
"baseline_plays": null,
"calibration_samples": <Q2=a→0Q2=b→Phase 3.5 import 回填或 Q2.3 估值>,
"data_collection": "<查 Q3 映射表,写 \"manual\" 或 \"adapter\">",
"pool_status": "<查 Q4 映射表,写 \"none\"/\"markdown\"/\"notion\">",
"data_layer": "markdown",
"hooks_installed": <查 Q5 映射表,写 bool true/false>,
"enabled_trend_sources": ["manual-paste"],
"enabled_perf_adapters": <Q2.1=a→[\"douyin-session\"]b→[\"xhs-explore\"]c→[\"youtube-data-api\"]d→[\"bilibili-stat\"]e→[\"linkedin-session\"];其他→[]>,
"last_bump_at": null,
"last_bump_self_audited": false,
"last_published_at": null,
"last_published_file": null,
"last_retro_at": null,
"last_trends_run_at": null,
"last_trends_added_count": 0,
"last_prediction_self_scored": false,
"last_self_scored_at": null,
"consecutive_directional_errors": [],
"pending_retros": [],
"shoots": [],
"in_progress_session": null,
"initialized_at": "<本地 ISO 8601 含时区,如 \"2026-05-05T20:11:13+08:00\"**不要用 UTC 的 Z 后缀**>"
}
```
2. **`rubric_notes.md`**
```
"正在创建 rubric_notes.md — 你的评分维度的真实来源。
用的是 v0 占位 rubric——等权 7 维(每个维度同等重要)。
为什么叫 v0:v0 是没校准前的占位。你的账号自己的真权重要从你
的数据反推,不是预设。跑完 5 篇有数据的内容后,会自动提议
升级到「校准 v1」(你的第一个真正校准过的 rubric)。
⚠️ rubric_notes.md 是 blind sub-agent (channel B) 的白名单文件——
只能含通用语言(公式 / 维度定义 / bucket 边界),不能含真实视频名 / 实绩。
每次 bump 升级时的 Memo(含证据数据 + 派生证据)写到 rubric-memo.md(下一步创建)。"
```
- 复制 `cheat-on-content/starter-rubrics/<form>-zero.md`cold-start)或 `<form>.md`(已有数据时仍可参考)
2.5. **`rubric-memo.md`****新**——配合 cheat-score-blind 隔离协议)
```
"正在创建 rubric-memo.md — bump 升级 Memo 累积档案。
这是 cheat-bump Phase 5 写入 Memo 全文(含真实视频名 + 实绩 + 派生证据)的位置。
为什么单独一个文件:blind sub-agent 的白名单是 rubric_notes.md
历史上 bump Memo 写进 rubric_notes.md 会让 blind sub-agent 通过白名单
拿到本该看不到的实绩数据——本文件是隔离修复,sub-agent 硬禁读本文件。
现在是空的,等第一次 cheat-bump 升级后 append 第一段 Memo。"
```
- 复制 `cheat-on-content/templates/rubric-memo.template.md` → `<user-repo>/rubric-memo.md`
3. **`script_patterns.md`**
```
"正在创建 script_patterns.md — 你的写作 pattern 沉淀(与 rubric 解耦)。
rubric_notes.md 教 Claude 怎么打分;
script_patterns.md 教 Claude 怎么写。"
```
- 复制 `cheat-on-content/templates/script_patterns.template.md`
4. **四个目录**`scripts/` + `predictions/` + `videos/` + `samples/`(都加 `.gitkeep`
```
"正在创建四个目录:
scripts/ — 拍前的草稿(cheat-seed 写或你写)
predictions/ — immutable 预测日志(hook 保护)
videos/ — 拍后的工作目录(cheat-shoot 创建子目录)
samples/ — 对标账号视频 / 转录(cheat-learn-from 创建子目录)
前三处用同一组 <date>_<id>_<short> 命名相互关联。
samples/ 按对标账号名分组:samples/<账号名>/<video-id>/。"
```
4.5. **`benchmark.md`**(仅 Phase 2.5 选 a/b 时)
```
"正在复制 benchmark.md 占位模板(实际内容由 cheat-learn-from 填)——
这是你的对标账号的中央 reference。
前期工具的 rubric / pattern / 选题方向感大量从这里推;
后期 N≥10 后影响淡出,但保留作 sanity check。"
```
- 复制 `cheat-on-content/templates/benchmark.template.md` → `<user-repo>/benchmark.md`
- **Phase 2.5 选 c 不创建** → benchmark.md 不存在,state 标 `benchmark_status: none`
4.7. **`audience.md`**
```
"正在创建 audience.md — 你账号的受众画像('谁在看')。
现在是空骨架。它和 rubric_notes.md 平行——rubric 教 Claude 怎么打分,
audience 告诉 Claude 你的观众是谁。跑够几篇复盘后跑 /cheat-persona
它会从评论数据聚类出真实画像,cheat-seed 选题写稿时就有了一面镜子。
注意:audience.md 由评论派生 → 含实绩信号 → blind 打分 sub-agent 硬禁读它。"
```
- 复制 `cheat-on-content/templates/audience.template.md` → `<user-repo>/audience.md`
- 如 Phase 2.5 选了 a/b(有 benchmark)→ Phase 5 清单里提示"可跑 `/cheat-persona — seed-from-benchmark` 先 seed 一份未验证画像"
5. **`WORKFLOW.md`** + **`STATUS.md`**
- 复制 templates/ 对应文件
6. **如果 Q5=是 → 安装 hooks**
- 读 `.claude/settings.json`(如不存在则创建空 `{}`
- merge 进 `hooks/prediction-immutability.json` 的 `hooks.PreToolUse`
- merge 进 `hooks/session-start.json` 的 `hooks.SessionStart`
- merge 进 `hooks/meta-logging.json` 的 hooks(如同时启用)
- 复制 `prediction-immutability.sh` + `session-start.sh` + `log-event.sh` 到 `.cheat-hooks/`chmod +x
- settings.json 里的 command 路径用 `${CLAUDE_PROJECT_DIR}/.cheat-hooks/`
7. **(Pool 选项 c—Notion)** 仅记录到 state file 的 `pool_status: notion`,后续 cheat-trends 调用时再处理
### Phase 3.5: import 流程(仅 Q2=b 且用户同意抓取)
如 Q2.2=现在装 → 走 adapter install + login(详见 [adapters/perf-data/<platform>/README.md](../../adapters/perf-data/))。
抓取成功后,对每条已发视频:
1. **建 video folder**`videos/<date>_<id>_<short>/`
- `<date>` = 视频实际发布日
- `<id>` = 12 位 hash,对 (title + 平台 ID) 做 sha256
- `<short>` = 标题前 3-8 字
2. **写 report.md**:从 adapter 抓回的数据(播放 / 点赞 / 评论 / 转发 / top 评论)填入
3. **询问用户原稿**"video 「{标题}」 你保留了原稿吗?"
- 是 → 用户提供 → 存为 `videos/<id>/script.md`
- 否 → 标 `script_lost`(仍建 video folder,只是 script.md 缺失)
4. **写 reconstructed prediction**`predictions/<date>_<id>_<short>.md`
- header 标 `**Reconstructed retrospective — NOT a blind prediction**`
- 7 维打分基于 script + 复盘段实绩数据**反向打**——明确这是非校准用途
- 不计入 calibration_samples(这是导入的历史,不是校准积累)
import 完成后:
- 派生 `baseline_plays` = 抓回视频的播放中位数 → 写入 state file
- 派生 confidence 等级 → 后续 cheat-predict 写预测时直接用
- 输出汇总:"已 import N 条历史。最近一条 X w 播放,中位数 Y w,已建 N 个 video folder + reconstructed predictions"
### Phase 4: 测试 hook 是否生效(仅当 Q5=是)
跑一次假的 Edit 拦截测试:
1. 创建临时文件 `predictions/_test_hook.md`,含 `## 预测\n[test]\n## 复盘\n`
2. 尝试 Edit 这个文件的 `## 预测` 段
3. 钩子应 exit 1 阻塞 → 报告"✅ immutability 钩子生效"
4. 删除测试文件
5. SessionStart hook 验证:直接调一次 `bash .cheat-hooks/session-start.sh` → 应输出报告(即使是空的也行)
如果钩子未生效 → **不要假装成功**,明确告诉用户:"钩子安装失败,可能是 .claude/settings.json 配置没生效。建议手动检查或重启 Claude Code。"
### Phase 4.5: 如 Phase 2.5 选 a → dispatch 到 /cheat-learn-from
如果用户在 Phase 2.5 选了 a(现在导对标账号)→ **自动触发 /cheat-learn-from**
```
✅ 脚手架 + hooks 装完。
下面立刻进入 /cheat-learn-from 帮你导入对标账号——
你 init 时选了"现在找",不让你又开一个会话才跑。
[invoke /cheat-learn-from]
```
cheat-learn-from 完成后回到 init 的 Phase 5。
如 Phase 2.5 选 b/c → 跳过 Phase 4.5,直接 Phase 5。
### Phase 5: 给"下一步该说什么"清单
```
✅ 初始化完成(rubric: v0calibration_samples: <N>confidence: <emoji 等级>
下次你可以直接说这些:
📝 写完一篇稿子 → "打分这篇 scripts/<...>.md"
🎯 准备发布前 → "启动预测 scripts/<...>.md"
🎬 拍完了 → "拍了 scripts/<...>.md" → 建 video folder + buffer +1
🚀 发布后 → "已发布 https://..."
📊 T+3 天 → "复盘 videos/<...>/"
📈 任何时候 → "状态"(看完整看板)
<如果 Q4=没有候选选题:>
🌱 现在跑 /cheat-seed 找选题?
- 没发过历史的:纯 brainstorm(兴趣 × 热点)
- 发过历史的(已 import):brainstorm 会基于你过去做过什么给推荐
回 "yes, seed" 立刻跑,回 "no" 你自己想。
💡 你的 confidence 是 <当前等级> —— 它会随着你跑更多复盘自动提升。
不要因为 confidence 低就跳过预测——预测的纪律本身就是工具的核心,
早期预测的"价值"是数据采集,不是决策。第 5 次复盘后 rubric 第一次校准,
confidence 会跨入 🟡 偏低;第 10 次后 🟢 中。
```
## Key Rules
1. **不假装成功**:任何步骤失败 → 明确告诉用户哪一步出错。绝不写"✅ 初始化完成"如果实际没完成
2. **不批量提问**:5 个问题一次问一个
3. **不静默 mkdir**:每创建一个文件都解释它的作用
4. **不强推 SQLite**:所有用户给 markdown,提一句"将来到 30 篇会建议升级"就够了
5. **state 字段统一**:删掉 mode / prediction_complexity / bucket_scheme 等枚举字段——单一用 calibration_samples 整数 + confidence 派生
6. **import 失败不阻塞**Q2=b 但 adapter 装失败 / 抓取失败 → 优雅降级到"标 calibration_samples 估值,不导入历史 video folder"
## Refusals
- 「跳过 Q1-Q5,直接给我创建所有文件」 → 拒绝。问题答案直接影响默认配置(content_form、cadence、hooks
- 「我已经在别处初始化过了,把那个项目的配置同步过来」 → 慎重。提示用户手动 cp 现有 `.cheat-state.json` 和 `rubric_notes.md`,不自动跨项目同步
- 「不装 hook 但保留 immutability 承诺」 → 允许,state 标 `hooks_installed: false`cheat-status 持续提示"你的 immutability 是君子协定"
## Integration
- 写完后,主 SKILL.md 的路由就解锁了所有其他子 skill
- `cheat-status` 读 `.cheat-state.json` 的 `calibration_samples` 字段决定显示哪个 confidence 等级
- 如 Q2=b 走了 import → 历史 reconstructed predictions 进 `predictions/` 和 `videos/<...>/`,但**不**计入 calibration_samples(不是真校准样本)
- `/cheat-seed` 读 `predictions/` 的所有历史 reconstructed prediction → brainstorm 时知道"用户过去做过什么"
## State 字段写入清单
| 字段 | 写入时机 | 来源 |
|---|---|---|
| `schema_version` | Phase 3 | 硬编码 "1.4" |
| `skill_version` | Phase 3 | 硬编码 "1.0.0" |
| `rubric_version` | Phase 3 | "v0" |
| `content_form` | Phase 3 | Q1 → 查映射表换 enum 值(**不是字母**) |
| `typical_duration_seconds` | Phase 3 | Q1.5 派生 |
| `target_publish_cadence_days` | Phase 3 | Q1.6 派生 |
| `rubric_form_mismatch` | Phase 3 | Q1≠a → true |
| `benchmark_status` | Phase 3 / 2.5 | Q2.5 答案派生 |
| `benchmark_name` | Phase 3 / 2.5 | Q2.5 用户提供 |
| `benchmark_sample_count` | Phase 3 / 2.5 | cheat-learn-from import 后回填 |
| `baseline_plays` | Phase 3.5(如 import 成功) | import 数据中位数;否则 null |
| `calibration_samples` | Phase 3 / Phase 3.5 | Q2=a→0Q2=b→Q2.3 估值或 import 数 |
| `data_collection` | Phase 3 | Q3 → 查映射表换 enum 值 |
| `pool_status` | Phase 3 | Q4 → 查映射表换 enum 值 |
| `enabled_perf_adapters` | Phase 3 | Q2.1 派生(如 Q2=a 则 `[]` |
| `hooks_installed` | Phase 3-4 | Q5 → bool(不是字符串) |
| `last_bump_at` / `last_published_at` / `last_published_file` / `last_retro_at` / `last_trends_run_at` | Phase 3 | 全部 `null` |
| `last_bump_self_audited` | Phase 3 | `false` |
| `last_trends_added_count` | Phase 3 | `0` |
| `last_prediction_self_scored` | Phase 3 | `false` |
| `last_self_scored_at` | Phase 3 | `null` |
| `initialized_at` | Phase 3 | now() 本地 ISO 8601,含 `+08:00` 时区,**不要 UTC `Z`** |
+399
View File
@@ -0,0 +1,399 @@
---
name: cheat-learn-from
description: 从对标账号导入 script + 数据 → 拆 pattern + 派生 base rubric 信号 → 写到 benchmark.md / script_patterns.md / rubric_notes.md。**这是工具最早期信号的来源**——cold-start 用户没自己历史时全靠对标,发过历史的用户也建议至少 1 个对标做 sanity check。触发词:"学这个账号"/"拆这几个对标视频"/"learn from"/"导入对标账号"/"找对标"。
argument-hint: <账号名> [— way: a (default) | b] [— append | --replace]
allowed-tools: Bash(*), Read, Write, Edit, Glob, WebFetch, Skill
---
# /cheat-learn-from — 对标账号导入
工具早期最重要的信号源是**对标账号**——你 init 完没数据,rubric 等权 v0 等于占星。但如果你能找一个你想做成那样的账号,导入 5-10 条它的高/中/低样本,工具就有了 anchor。
后期当你自己 calibration_samples ≥ 10 时,benchmark 影响自然减弱——你的真实数据成为主要信号源。但 benchmark.md **不删**,仍是 cheat-seed brainstorm 的 reference frame。
## Overview
```
[用户:学这个账号 / 启动 cheat-learn-from]
[Phase 0: 检查 benchmark 状态]
[Phase 1: 选 input 方式(Way a 默认)]
[Phase 2: 收集材料]
Way a: 用户粘 N 条 script 文本 + 数据
Way b: 用 whisper 转录 samples/ 目录里的视频
[Phase 3: 询问每条样本的"印象判断"(高/中/低 + 为啥)]
[Phase 4: Claude 拆 pattern + 派生 rubric 信号]
[Phase 5: 用户 review → 改 → 落盘]
[Phase 6: 写 benchmark.md / script_patterns.md / rubric_notes.md]
[Phase 7: 更新 state.benchmark_status]
```
## Constants
- **MIN_SAMPLES = 3** — 最少 3 条样本(少于拆不出 pattern)
- **RECOMMENDED_SAMPLES = 5-10** — 推荐区间,平衡信号量 vs 用户工作量
- **MAX_SAMPLES_PER_RUN = 15** — 单次导入上限——再多 Claude context 不够 + 用户也累
- **DEFAULT_WAY = a** — Way a 简单 + 准确,是 default
## Inputs
| 必填 | 来源 |
|---|---|
| `<账号名>` | 用户参数;缺失则询问 |
| `.cheat-state.json` | 状态文件 |
| Way a: 用户粘的 script 文本 + 数据 | 对话 |
| Way b: `samples/<账号名>/*.mp4` 等视频文件 | 用户提前下载好放进去 |
## Workflow
### Phase 0: 检查 benchmark 状态
`.cheat-state.json``benchmark_status`
| 状态 | 处理 |
|---|---|
| `none` | 首次导入——继续 Phase 1 |
| `pending` | 用户之前答应等下找——继续 Phase 1 |
| `imported` 已有 benchmark | 询问"你已有 benchmark [当前名],N 条样本。要做什么? a) 追加新视频到当前 benchmark b) 替换为新 benchmark c) 只看不改" |
参数解析:
- `--append` → 追加到现有 benchmark
- `--replace <new-name>` → 用新 benchmark 替换(旧的归档到 benchmark.archived/
- 没标志 + 已有 benchmark → 走上面询问
### Phase 1: 选 input 方式(**两个独立维度**)
每条样本 = **script** + **数据**。两者怎么拿是独立的——你可以混搭。
#### Phase 1a: script source(怎么拿稿子)
```
script 怎么拿?
a) **粘文本(最简单,推荐)**
- 你自己整理过 / 用工具提取过——直接粘进对话
- 工具推荐(按方便程度排):
抖音 / 小红书:
- 微信小程序「轻抖」—— 粘视频链接 → 自动提取文案 + 评论。最快
- 类似工具:"视频解析助手" / "短视频文案提取" 等小程序
- 通常有免费额度,重度使用收费
B 站 / YouTube
- 视频页面有"显示字幕/文字记录"按钮(如果 UP 主开了)
- 第三方:DownSub / SaveSubs / yt-dlp --write-auto-sub
公众号 / Substack
- 直接复制网页文字
b) **whisper 转录视频文件**
- 你下载了视频到 samples/<账号名>/<video>/source.mp4
- 需要装 whisper-cpp + ffmpeg(见 adapters/script-extraction/whisper/README.md
- 转录可能有错别字 / 漏字 / 标点不准——准确度比 a 差
c) **跳过 script,只用元数据 + 印象**
- 你拿不到稿子也懒得用工具
- 后果:pattern 拆不深(只能看标题 / 数据 / 你的印象),但 rubric 信号还行
- 适合"先快速搭起来,将来补"
回 a / b / c。
```
#### Phase 1b: data source(怎么拿播放/点赞/评论)
```
数据(播放 / 点赞 / 评论 / 转发)怎么拿?
a) **手填数字(最简单)**
- 你查一下账号后台或视频页面,告诉我数字
- 不需要装任何工具
b) **adapter 自动抓(如已配置)**
- 你已经装了 perf-data adapter(如 douyin-session
- 给我视频 URL,工具自己抓数据 + top 评论
- 评论数据更全(手填只能告诉我数字,adapter 能拿到具体评论文本)
回 a / b。
```
**最常见的组合**
- 完全零依赖路径:1a + 1b(粘文本 + 手填)—— 5 分钟搞定
- 评论质量优先:1a + 2b(粘文本 + adapter 抓)—— 装了 adapter 就走这个
- 拿不到稿子兜底:1b + 1bwhisper + 手填)
### Phase 2: 收集材料
按 Phase 1a + 1b 的组合走对应路径。
**通用纪律**:每条样本最少要有 (script 或 transcript 或 N/A标记) + 数据 (4 项基础:播放/点赞/评论/转发)。
#### Path A: 粘文本(Phase 1a=a
```
好。我们一条一条来。最少 3 条,推荐 5-10 条。
第 1 条 script,把整段粘下面(段落版,不要字幕格式):
```
收到 script → 算 video_idsha256(script_content)[:12])→ 进 Phase 2 数据采集。
#### Path B: whisper 转录(Phase 1a=b
```
先确认 whisper 装了:
[运行 `command -v whisper-cpp` 或 `command -v whisper` 检测]
如果没装:
❌ whisper 没装。三选一:
- brew install whisper-cpp(推荐——快)
- pip install openai-whisperPython 慢些)
- 切回 Path A 自己粘文本(推荐用轻抖等小程序)
装好后:把视频文件放到 samples/<账号名>/<video-name>/source.mp4
(一个视频一个子目录)
放好后告诉我"放好了 N 条",我转录。
```
用户放好后:
1. Glob `samples/<账号名>/*/source.*` 找视频文件
2. 对每个视频跑 `bash adapters/script-extraction/whisper/run.sh <video> samples/<账号名>/<id>/`
3. 失败项报告但继续其他
4. 进 Phase 2 数据采集
#### Path C: 跳过 scriptPhase 1a=c
直接进 Phase 2 数据采集——告诉用户"没 script 我能拆的 pattern 仅限标题级别 / 你的印象,rubric 信号正常拆"。
#### Phase 2 数据采集(Phase 1b=a 手填 / b adapter
**如 Phase 1b=a(手填)**
```
第 1 条数据:告诉我
- 标题
- 播放量
- 点赞
- 评论数(不是评论内容,是数字)
- 转发 / 分享数
格式随意,能识别就行。例如:
"标题:怎么停止期待
播放:71w / 点赞 2.4w / 评论 899 / 转发 1.8w"
如果你能再粘 top 5-10 条评论(带赞数)更好——pattern 拆能挖到模因层。
```
**如 Phase 1b=badapter**
```
你说装了 adapter(如 douyin-session)。给我每条视频的 URL
我跑 adapter 自动抓数据 + top 评论。
第 1 条 URL
```
收到 URL → 调对应 adapter → 写数据 + 评论到 samples/<账号名>/<id>/meta.md。
**通用**:继续问第 2 条 / 第 3 条 / ... 用户说"够了"或达到 MAX_SAMPLES_PER_RUN 时进 Phase 3。
### Phase 3: 询问"印象判断"
对每条样本(不管 Way a 或 b),收完数据后**追加问印象**:
```
你看完 / 听完这条视频的印象,算这个账号的:
a) 高表现样本(代表作 / 你想做成这样的)
b) 中表现样本(普通水准 / 不上不下)
c) 低表现样本(不算这个账号的代表 / 你不想做成这样)
为什么?(一句话——这个判断比数据更能告诉我你想做什么风格)
```
记录 (impression_label, impression_reason) 到内存。
> 注意:印象**可以**和数据冲突——比如某条数据高但用户觉得"不算代表作"。这种冲突本身是有用信号,记录下来。
### Phase 4: Claude 拆 pattern + 派生 rubric 信号
阅读所有 (script, 数据, 印象) → 自己分析:
#### 4a. Script patterns
按 script_patterns.md 的 cheat sheet 框架拆:
- 开头钩子:3 种类型分布(场景代入 / IS 戏仿 / 数据反转)
- 主体结构:几段 / 怎么切
- 句式 / 句长 / 节奏:短句还是长句、有没有标志性句式
- emotional 标记 / 双声道
- 致谢段 / 收尾
- 高频词汇 / 词汇风格
输出 N 个具体 pattern(每个引用具体样本作证据)。
#### 4b. Rubric 信号(**仅定性,不给数值权重**)
对每条样本打 7 维分(用通用维度),然后看:
- 高表现样本(按用户印象)共有哪些维度高?
- 低表现样本共有哪些维度低?
- 哪些维度在高/低样本之间无差异(说明不是关键维度)?
输出**定性方向**(不是数值权重):
- "ER 看起来重要"3/3 高样本 ER ≥4
- "SR 看起来不显著"(高低样本 SR 分布无差异)
- "MS 高的样本评论区有明显模因爆发"
### Phase 5: 用户 review
一次性展示所有结果给用户:
```
我从你给的 N 条对标视频拆出:
📝 N 个 script pattern
1. **[Pattern 1 名称]**[一句话描述] → 证据:[样本 X / Y]
2. ...
🎯 Rubric 定性信号:
- 看起来重要的维度:ER / QL / MS(每个有 N 条高样本支持)
- 看起来不显著的维度:SR / NA
- 给的初始建议:你的对标账号是 [情感+金句驱动型] / [数据驱动型] / [类比讲解型] / ...
- **不直接给数值权重**——5-10 样本拟合容易过拟合,先用作 tier-2 信号
🎨 选题方向感:
- 主题分布大概:[主题 A 40% / 主题 B 30% / ...]
- 调性:[一句话]
回 "ok" 我落盘,
或指出哪些 pattern / 信号你不认同("Pattern X 我觉得不准" / "Rubric 信号 Y 错了")。
```
用户反馈循环:
- "ok" → Phase 6 落盘
- 用户挑刺 → Claude 改 → 重新展示 → 直至确认
### Phase 6: 落盘
#### 6a. benchmark.md
按 [templates/benchmark.template.md] 格式写到 `<user-channel>/benchmark.md`
- 账号信息(账号名、URL、调性、粉丝量级——用户提供)
- 导入的样本表
- 基础 rubric 派生(仅定性)
- 选题方向感
`--append` → 在现有 benchmark.md 的样本表追加新行 + 重新拆 pattern;不重写整个文件。
`--replace` → 把现有 benchmark.md 移到 `benchmark.archived/<旧账号名>_<日期>.md`,写新的。
#### 6b. samples/<账号名>/
为每条样本建子目录:
```
samples/<账号名>/<video-id>/
├── source.mp4 (Way b 才有,Way a 没有)
├── transcript.md (从粘文本写 / whisper 转出来)
└── meta.md (标题 / 数据 / 印象 / 印象理由)
```
#### 6c. script_patterns.md
`<user-channel>/script_patterns.md` 加新段:
```markdown
## 对标 [账号名] 借鉴(imported on YYYY-MM-DDN 条样本)
> 这些 pattern 来自对标账号——**Imported, untested on my channel**。
> 实拍验证后(≥2 次跑出 + 复盘确认有效)再去掉这个标记,升入正式 pattern。
### Pattern A: [一句话名]
**来自**: [样本 X]
**描述**: [详细]
### Pattern B: ...
```
#### 6d. rubric_notes.md
`<user-channel>/rubric_notes.md` 加 / 更新"benchmark-derived initial signals"段:
```markdown
## Benchmark-derived initial signals
> 来自 benchmark.md 的对标账号 [账号名]N=N 样本,import on YYYY-MM-DD)。
> **仅定性方向,不直接采纳为数值权重**——5-10 样本拟合容易过拟合。
> 等你自己 N≥5 校准样本后正式 bump 时**再决定**是否调权重。
- 看起来重要的维度: ER / QL / ...
- 看起来不显著的维度: SR / NA / ...
- Claude 给的初始建议: [一句话定性]
```
### Phase 7: 更新 state file
```json
{
"benchmark_status": "imported",
"benchmark_name": "<账号名>",
"benchmark_sample_count": <N>
}
```
## Key Rules
1. **Way a 默认**——简单 + 准确。Way b 仅给"找不到 script 只有视频"的兜底
2. **必须问印象**——纯看 transcript 拆 pattern 容易抓表面,加用户印象才挖到深层
3. **Rubric 信号仅定性**——不直接给数值权重。5-10 样本拟合过拟合
4. **pattern 默认标 untested**——避免污染用户自己的 pattern 库
5. **不直接抓视频**——下载是用户的事,避免 TOS + 反爬
6. **可重复跑**——`--append` 加新视频,`--replace` 换账号
7. **MIN_SAMPLES = 3**:少于 3 拆不出 pattern,拒绝继续
## Refusals
- 「跳过印象判断,直接拆」 → 拒绝。印象是关键 input
- 「我只能给 1 条样本」 → 拒绝。最少 3 条
- 「直接给我数值权重」 → 拒绝。Phase 4 只给定性信号
- 「能不能不写 transcript 文件,只在内存里拆」 → 不行。transcript 持久化是后续 cheat-retro Phase 4b diff 的依据
- 「帮我下载对标视频」 → 拒绝。引导用户用 yt-dlp / BBDown 等工具自己下
## Integration
- 上游:`/cheat-init` Phase 2.5 在 cold-start 用户时**强烈建议**跑 `/cheat-learn-from`calibration 用户**可选**
- 上游:`/cheat-status``benchmark_status=pending` + 距 init >24h 时持续提醒
- 下游:`/cheat-seed` brainstorm 时读 benchmark.md → 知道用户对标方向
- 下游:`script_patterns.md` 加新段,cheat-seed 写 draft 时按 pattern 选结构
- 下游:`rubric_notes.md` 加 benchmark-derived signals 段,cheat-bump 时作为参考之一
- N≥10 提示:`cheat-status` 在用户 calibration_samples ≥10 时提示"你已有足够自己数据,benchmark 影响淡出(保留作 sanity check"
## benchmark 何时淡出
不是死磕样本数,是 **Claude 判断"用户数据信号是否已超过 benchmark"**
- **默认参考**calibration_samples ≥ 10 → benchmark 影响淡出
- **可以更早**:N=5 但用户的 (打分, 实绩) 配对里出现 ≥3 条与 benchmark pattern 不一致的——说明你账号已经走出对标的路径
- **可以更晚**:N=15 但用户的样本都很相似(都做同一类内容),benchmark 仍有信号价值
判断条件 + 默认值都在 cheat-status 触发器 #19 / cheat-seed Phase 0 里实现。
**淡出后**
- cheat-seed brainstorm 仍读 benchmark.md,但**优先级低于用户自己的 predictions/**
- rubric_notes 的 benchmark signals 段标 `**Status: superseded by user data**`,不删但不再主导
- benchmark.md **不删**——保留作 sanity check(看你账号是否真的偏离对标方向太远)
**任何时候用户主动**:跑 `/cheat-learn-from --replace none` 完全解除 benchmark 影响
## 与其他 skill 的区别
| Skill | 用途 |
|---|---|
| `/cheat-learn-from` | **从对标账号**导入 pattern / rubric 信号(一次性 / 偶尔追加) |
| `/cheat-seed` | brainstorm 选题 + 写 draft(读 benchmark.md 作参考) |
| `/cheat-trends` | 抓今天的热点(与 benchmark 无关) |
| `/cheat-bump` | 升级 rubric(用户自己 N≥5 后用真实数据,不直接用 benchmark signals |
+234
View File
@@ -0,0 +1,234 @@
---
name: cheat-migrate
description: 把老用户的 .cheat-state.json 升级到当前 schema_version。读 migrations/registry.md 算迁移链,按顺序应用每一步迁移文件。幂等:跑两次结果一样。失败停在中间版本不前进。触发词:"迁移"/"升级 state"/"migrate"/"我的 state 是老版本"/"schema 版本不对"。
argument-hint: [— from: <version>] [— to: <version>] [— dry-run]
allowed-tools: Bash(*), Read, Write, Edit, Skill
---
# /cheat-migrate — Schema 版本迁移
把用户 `.cheat-state.json` 从旧 `schema_version` 升级到 cheat-on-content 当前期望的 `LATEST_SCHEMA`
---
## Overview
```
[用户:迁移 / 或 SessionStart 提示后用户跑]
[Phase 0: 读 .cheat-state.json + migrations/registry.md → 确定迁移链]
[Phase 1: dry-run(默认)展示迁移计划,等用户确认]
[Phase 2: 备份 .cheat-state.json → .cheat-state.json.backup-<timestamp>]
[Phase 3: 按顺序对每个 step 应用对应迁移文件的 HOW 段]
[Phase 4: 验证升级后 state 文件能被解析 + schema_version 已更新]
[Phase 5: 报告 + 提示如有备份需要清理]
```
---
## Constants
- **REGISTRY_PATH = `${SKILL_DIR}/../../migrations/registry.md`** — 版本链单一来源
- **MIGRATIONS_DIR = `${SKILL_DIR}/../../migrations/`** — 迁移文件目录
- **DRY_RUN_BY_DEFAULT = true** — 首次跑展示计划,不直接改文件
- **BACKUP_BEFORE_WRITE = true** — 写之前必备份;备份文件保留至下次成功 init / 用户手动清理
- **STOP_ON_STEP_FAILURE = true** — 任何 step 失败 → 停在中间版本,不前进,不回滚
> 💡 调用时覆盖:`/cheat-migrate — dry-run: false` 直接执行 / `/cheat-migrate — to: 1.2` 仅升到指定版本
---
## Inputs
| 必填 | 来源 |
|---|---|
| `.cheat-state.json` | 用户项目根 |
| `migrations/registry.md` | LATEST_SCHEMA + 版本链表 |
| `migrations/<from>-to-<to>.md` | 每步具体迁移指令 |
---
## Workflow
### Phase 0: 确定迁移链
1.`.cheat-state.json` → 解析 `current_version = state.schema_version`
2.`migrations/registry.md` → 解析 `LATEST_SCHEMA` 字段(行:`LATEST_SCHEMA = "X.Y"`
3. 解析 `args.to` 覆盖(如有);否则 target = LATEST_SCHEMA
4. 解析 `args.from` 覆盖(罕见场景:用户的 state 文件 schema 字段坏了,强制指定起点)
5. **状态判断**
- `current_version == target` → 输出"✅ state 已是 {target},无需迁移" → 退出
- `current_version > target`(比如用户跑了 dev 版又切回 release)→ 报错"无法降级,请手动调整或重新 init"
- `current_version < target` → 继续,从注册表查出迁移链
6. 从注册表"版本链"表算出 `chain = [(from, to, file), ...]`,按顺序串起 current → target
如果某一步在注册表里缺失(比如 `current_version` 不在表里)→ 报错并展示"目前已知版本:[1.0, 1.1, ...]",让用户检查。
### Phase 1: dry-run
输出迁移计划:
```
📋 迁移计划
当前版本: 1.0
目标版本: 1.2
将按顺序跑 2 步:
[1/2] 1.0 → 1.1MINOR
新增字段:typical_duration_seconds, target_publish_cadence_days, ...(共 12 字段)
删除字段:mode, prediction_complexity, bucket_scheme
详见: migrations/1.0-to-1.1.md
[2/2] 1.1 → 1.2MINOR
新增字段:[...]
详见: migrations/1.1-to-1.2.md
⚠️ 备份位置: .cheat-state.json.backup-<timestamp>
继续吗?回 yes 执行 / no 退出 / dry-run-detail 看每步具体改什么。
```
`args["dry-run"] == false` 或用户回 yes → 进 Phase 2。
### Phase 2: 备份
```bash
cp .cheat-state.json .cheat-state.json.backup-$(date +%s)
```
输出:"📦 备份到 .cheat-state.json.backup-1714838400"
### Phase 3: 按顺序应用每步
对 chain 里的每个 (from, to, file)
1. 输出 "→ [N/M] 应用 {file}..."
2.`migrations/<file>` → 找到 `## HOW (Claude steps for /cheat-migrate)`
3. **按段内自然语言步骤逐项执行**——这是关键:迁移是 Claude 读 markdown 跑的,不是 python 脚本
4. 每步完成后:
- 更新内存里的 state.schema_version = to
- **原子写**到磁盘(写 .tmp → rename
5. 如某步失败:
- 输出"❌ {file} 第 N 步失败:{error}"
- 不前进、不回滚(state 已停在前一步成功的中间版本)
- 提示:"已停在 schema_version: {last_success_version}。修复后重跑 /cheat-migrate 会从这里继续"
- 退出
### Phase 4: 验证
升完后:
1.`.cheat-state.json` → 解析 → 应能成功
2. 检查 `schema_version == target`
3. 检查所有"必填字段"非缺失(参照 [shared-references/state-management.md](../../shared-references/state-management.md) 完整 schema
4. 失败 → 报错"迁移完成但验证失败:{detail}。state 文件可能不一致——查看备份恢复"
### Phase 5: 报告
```
✅ 迁移完成
从: 1.0
到: 1.2
应用步骤: 2
state 文件现在含 X 字段,全部通过验证。
📦 备份保留:.cheat-state.json.backup-1714838400
(确认一切正常后可手动 rm;下次成功 /cheat-init 也会清理过期备份)
下一步建议:
- 跑 /cheat-status 确认看板正常
- 如有 hooks 重装需求,跑 bash <skill_repo>/install.sh --reinstall-hooks
```
---
## Key Rules
1. **幂等**:在已升过的 state 上重跑应该立刻退出"无需迁移",**不**重复应用步骤。靠对比 `current_version == target` 实现
2. **不跳版**1.0 → 1.3 必须按 1.0→1.1→1.2→1.3 顺序,每步独立可恢复。不允许"直接升 1.0 → 1.3 的合并 migration"
3. **不静默兼容**state 文件 schema_version 不识别 → 明确报错"未知版本 X,最近已知版本 Y",不假装能继续
4. **失败停在原地**:第 N 步失败时 schema_version 停在 N-1 已成功的版本,不回滚到迁移前。重跑能从断点继续
5. **备份是硬约束**:写之前必有备份。即使用户跑 `--dry-run: false`,备份动作仍执行
6. **不动 predictions / rubric / videos**:只改 `.cheat-state.json`。其他用户数据由各自 skill 负责,迁移 skill 不碰
7. **MAJOR vs MINOR 透明**dry-run 输出必标 (MAJOR) / (MINOR)。MAJOR 时额外提示"老 skill 用旧字段读会出问题,迁移完不能回退到老 skill 版本"
---
## Refusals
- 「跳过 dry-run,立刻覆盖我的 state」 → **允许**`--dry-run: false`),但备份仍强制执行
- 「我的 state 损坏了 / schema_version 字段没了,能不能猜一个版本来跑」 → 允许指定 `--from: 1.0`,但要警告"基于猜测的迁移可能导致字段错位"
- 「降级到旧版本(current > target)」 → 拒绝。schema 演进单向。要降级请手动 cp 历史 git 快照
- 「合并多步迁移成一个 atomic」 → 拒绝。每步独立可恢复是设计核心
- 「在跑 cheat-bump / cheat-predict 中途调 migrate」 → 拒绝。等其他 skill 完成再跑,避免 in_progress_session 状态被破坏
---
## Integration
- 上游:SessionStart hook 检测 `state.schema_version != LATEST_SCHEMA` → 输出红色警告 + 建议跑 `/cheat-migrate`
- 上游(手动):用户 git pull 拉了新版后,看 CHANGELOG 标 BREAKING → 主动跑
- 下游:跑完后所有其他 skill 读 state 都能拿到最新字段
-`cheat-init`init 写新 state 时直接用 LATEST_SCHEMA,不需要走 migrate
-`install.sh --reinstall-hooks`:迁移**不**重装 hook 脚本(hook 脚本属于 skill 包代码,不属于用户 state)。这两件事解耦
---
## State 字段读写
本 skill **写**
- `schema_version`(每步成功后更新)
本 skill **读**
- 所有现有字段(取决于具体 migration 文件的 HOW 步骤)
本 skill **绝不**写:
- `calibration_samples` / `pending_retros` / `shoots` 等业务状态(这些是其他 skill 的职责)
- 例外:迁移文件明确说"派生新字段值时需要扫 predictions/ 算 baseline_plays",那是初始化新字段,不是改老字段
---
## Examples
### 示例 1:用户从 v0.1.0 升到 v0.2.0(假设 0.2 引入 schema 1.2
```
用户:迁移
Claude: [跑 cheat-migrate]
Phase 0: current=1.1, target=1.2, chain=[(1.1, 1.2)]
Phase 1: dry-run 输出计划
用户: yes
Phase 2: 备份
Phase 3: 应用 1.1-to-1.2.mdMINOR:新增 platform_metrics_url 等字段)
Phase 4: 验证 OK
Phase 5: 报告 ✅
```
### 示例 2:用户跳了多版
```
用户:我从 v0.1.0 升到 v0.5.0state 还是 1.0
Claude: [跑 cheat-migrate]
Phase 0: current=1.0, target=1.4 (LATEST), chain=[(1.0, 1.1), (1.1, 1.2), (1.2, 1.3), (1.3, 1.4)]
Phase 1: dry-run 输出 4 步计划
...
```
### 示例 3:迁移中途失败
```
Phase 3:
→ [1/4] 应用 1.0-to-1.1.md ✓
→ [2/4] 应用 1.1-to-1.2.md ✓
→ [3/4] 应用 1.2-to-1.3.md... ❌ 失败:用户 baseline_plays 字段含非数字值,无法转 int
state 已停在 schema_version: 1.2。
修复 .cheat-state.json 后重跑 /cheat-migrate 会从 1.2 → 1.3 继续。
```
+179
View File
@@ -0,0 +1,179 @@
---
name: cheat-persona
description: 从复盘评论数据派生 / 刷新账号的受众画像,写入 audience.md。这是和 rubric 平行的第二个派生物——rubric 答"怎么打分"persona 答"谁在看"。cheat-seed 选题 / 写稿时读它。**audience.md 含实绩信号,cheat-score-blind 硬禁读**。触发词:"构造受众画像"/"更新 persona"/"我的观众是谁"/"build persona"/"刷新受众画像"/"看看我的受众画像"。
argument-hint: [— seed-from-benchmark] [— rebuild]
allowed-tools: Bash(*), Read, Write, Edit, Glob, Grep
---
# /cheat-persona — 受众画像派生
`predictions/*.md` 复盘段的评论数据,聚类出账号真实受众画像,写入 `audience.md`
---
## 核心定位
persona 是和 rubric **平行**的第二个派生物,**不是 rubric 的一部分**
```
复盘数据(评论 + 完播 + 转粉)
├──→ rubric 进化(cheat-bump —— "怎么打分"
└──→ 受众画像(cheat-persona —— "谁在看"
两者都喂给 cheat-seed,但用途不同
```
- **rubric**:这稿子会不会爆 → 喂 cheat-predict 打分
- **persona**:谁会因为这条多看 3 秒 / 留评论 / 转发 → 喂 cheat-seed 选题 + 写稿
**绝不混**persona 不进打分公式。rubric 的 AB 维度(受众广度)可以参考 persona,但那是 cheat-bump 的事,不是这里。
## ⚠️ 污染隔离(不可省)
`audience.md` 从复盘评论派生 = **含已发布作品的实绩信号**。因此:
- `audience.md` 在 [cheat-score-blind](../cheat-score-blind/SKILL.md) 的 hard refusal list 里,refusal_code `blocked_audience`
- persona 影响 cheat-seed **写什么**creative direction),不影响 cheat-predict **怎么打分**blind sub-agent 永远不读 audience.md
- 这是干净的:persona 塑造的内容进了成稿,blind sub-agent 照成稿本身打分——没有 leak。leak 只会发生在 sub-agent 能读 audience.md "因为这受众爱 X 所以加分" 的情况,而它读不到
## Overview
```
[用户:构造受众画像 / 更新 persona]
[Phase 0: 收集数据 — 扫 predictions/*.md 复盘段评论 + benchmark.md]
[Phase 1: 数据量判定 → 派生 Confidence 等级]
[Phase 2: 评论聚类 — 自我认同 / 情绪寄存 / 反驳点 / 语言]
[Phase 3: persona × rubric 交叉检验]
[Phase 4: 写 audience.md(覆盖式重建,header 记 version + last_rebuilt]
[Phase 5: 控制台报告 + 跟上次画像的 diff]
```
## Constants
- **AUDIENCE_PATH = audience.md** — 受众画像落盘位置
- **MIN_RETROS_FOR_DATA_GROUNDED = 3** — 复盘数 ≥3 才算"数据扎实"(可基于评论质量软判断)
- **MIN_COMMENTS_PER_TRAIT = 3** — 一条"验证特征"至少要 3 条评论证据,否则降到"假设特征"
- **SEED_FROM_BENCHMARK = auto** — 无自己复盘数据但有 benchmark 时,seed 一份未验证画像
> 💡 调用覆盖:`/cheat-persona — seed-from-benchmark`(强制用 benchmark seed/ `— rebuild`(即使数据没变也重建)
## Inputs
| 来源 | 用途 |
|---|---|
| `predictions/*.md``## 复盘` 段 | **主数据源**——top 评论(带赞数)。persona 的金矿 |
| `videos/*/report.md` | 完播 / 转粉率——薄信号,推"留得住 vs 留不住" |
| `benchmark.md` | 冷启动 seed——"看对标的人 ≈ 你想要的人" |
| `rubric_notes.md` | Phase 3 交叉检验用——persona 食欲 vs rubric 校准现实 |
| `audience.md`(如已存在) | 上一版画像,用于 Phase 5 diff |
## Workflow
### Phase 0: 收集数据
1. Glob `predictions/*.md`,对每个文件读 `## 复盘` 段(**只读复盘段——这是 channel A,本来就看实绩**
2. 抽取每篇的 top 评论(带赞数)+ 实绩 bucket
3. 统计:有评论的复盘篇数 `N_retros`、评论总数 `N_comments`
4.`benchmark.md`(如存在)
5.`audience.md`(如已存在)→ 留作 Phase 5 diff
### Phase 1: 数据量判定 + Confidence
| 情况 | Confidence | 行为 |
|---|---|---|
| `N_retros == 0` 且无 benchmark | 🔴 无数据 | **不强行造**——告诉用户"persona 需要复盘数据。先跑几篇 cheat-retro,或导 benchmark",退出 |
| `N_retros == 0` 但有 benchmark | 🟠 benchmark-seed 未验证 | seed 一份 aspirational persona**全文标"未验证"** |
| `N_retros` 1-2 | 🟡 早期信号 | 能产出但特征多落"假设"段 |
| `N_retros` 3-5 | 🟢 数据扎实 | 正常产出 |
| `N_retros` ≥6 | 🔵 稳健 | 正常产出 + 可做更细的食欲分层 |
Confidence 等级写进 audience.md header。
### Phase 2: 评论聚类
对收集到的所有评论,按四个维度聚类:
1. **自我认同**——"我也是…" / "这就是我" / "作为一个…" 模式。统计哪类身份反复出现("大厂打工人" / "一人公司" / "考研党" / ...
2. **情绪寄存**——观众来评论是为了什么情绪?被验证("说得太对了"/ 宣泄("我也好累"/ 抬杠("我不同意"/ 求助("那该怎么办")。统计占比
3. **反驳点**——哪些观点引来稳定的反对声。这是 persona 边界
4. **语言**——他们怎么说话。玩梗密度、真诚 vs 戏谑、有没有复制你的金句
**聚类纪律**
- 一条"验证特征"至少 `MIN_COMMENTS_PER_TRAIT`(3)条评论证据。不够 → 降到"假设特征"段
- 每条特征**必须**能引出具体评论 + 出处(哪篇 prediction 的复盘段)+ 条数
- 发现"反画像"信号(你以为的受众 vs 实际评论的人不一样)→ 写"反画像"段
### Phase 3: persona × rubric 交叉检验
`rubric_notes.md` 当前 rubric + 校准历史。检查:
- persona 说"受众爱 X 类主题" → rubric 校准池里 X 类主题真的 over-perform 吗?
- 不一致 → 在 audience.md 的"persona × rubric 交叉检验"段 **flag 出来**
诚实要求:两个派生物矛盾时**不要**强行调和。明确写"persona 说 Arubric 校准说 B,待下次复盘澄清"——矛盾本身是信号。
### Phase 4: 写 audience.md
**覆盖式重建**(不是 append)——persona 是活文档,每次 rebuild 重写全文。但:
- header 的 `Persona 版本` +1v0 → v1 → v2
- header 记 `Last rebuilt` 日期 + `数据基础`N 篇复盘 / M 条评论)+ `Confidence`
- 文件底部"版本历史"段 **append 一行**`vN — 基于 M 篇复盘 / K 条评论,主要变化:...`(这是唯一保留的历史;不搞 memo 累积——persona 是活文档不是公式)
用 [templates/audience.template.md](../../templates/audience.template.md) 的结构。
⚠️ **不走版本 memo / 不调跨模型审**——persona 不是高风险不可逆动作(写错了重跑一次就好),过度工程没必要。
### Phase 5: 报告
```
✅ 受众画像已更新:audience.md(v2,🟢 数据扎实)
数据基础:4 篇复盘 / 87 条评论
核心画像:25-35 岁职场人,来找情绪共鸣不来找信息……
跟 v1 的主要变化:
- 新验证:"深夜刷手机" 场景共鸣强(v1 是假设,本次 17 条评论验证)
- 新反画像:原以为"学生党"是受众,但评论里学生占比 <5% → 移到反画像
- ⚠️ 交叉检验 flagpersona 说受众爱"职场吐槽",但 rubric 校准显示职场类 composite 偏低——下次复盘留意
下一步:
- cheat-seed 选题 / 写稿时会自动参考这份画像
- 再跑 3 篇复盘后建议再 /cheat-persona 刷新
```
## Key Rules
1. **数据派生,不手写**——persona 必须来自评论聚类。用户想手动加特征 → 允许,但标 `user-asserted`(未经数据验证)
2. **证据强制**——验证特征必须带评论条数 + 出处。无证据的进"假设"段
3. **覆盖式重建**——每次 rebuild 重写 audience.md 全文,只在版本历史段 append 一行
4. **不进打分**——persona 永远不喂 cheat-predict / cheat-score-blind。它是 cheat-seed 的 creative lens
5. **矛盾不调和**——persona × rubric 冲突时如实 flag,不强行编一个故事
6. **冷启动诚实**——没数据就说没数据,benchmark seed 全程标"未验证"
## Refusals
- 「我觉得我的受众就是 X,你直接写进 audience.md」 → 可以写,但标 `user-asserted` 放"假设特征"段,不放"验证特征"。persona 的价值在于数据 vs 你的幻想之间的 gap
- 「persona 也给 cheat-predict 用,让打分更准」 → 拒绝。persona 是实绩派生物,进打分 = 把 channel B 的隔离打穿。persona 只服务 cheat-seed
- 「跳过评论聚类,你凭感觉给我画一个」 → 拒绝。凭感觉画的是营销话术不是 persona。没评论数据就老实说"先去复盘"
- 「把 persona 写进 rubric_notes.md,省一个文件」 → 拒绝。rubric_notes.md 是 blind 白名单,写 persona(实绩派生)进去 = 实绩泄漏漏洞重演(见 observation-lifecycle.md 的 leak guard
## Integration
- 上游:`cheat-retro` 每完成一篇复盘 → flag "已累计 N 篇复盘,可跑 /cheat-persona 刷新画像"
- 上游:`cheat-init` 创建空 audience.md 骨架;如导了 benchmark → 提示可 `/cheat-persona — seed-from-benchmark`
- 下游:`cheat-seed` Mode A/B/C 读 audience.md 作为"这个 persona 会在乎吗"的镜子
- 下游(phase 2 路线):`cheat-recommend` persona-fit 排序;`cheat-status` persona 新鲜度 nag
- **隔离**`cheat-score-blind` 硬禁读 audience.mdrefusal_code `blocked_audience`
## Known limitations
1. **评论 ≠ 全部受众**——留评论的是受众里最活跃的一小撮(沉默大多数不在数据里)。persona 偏向"会评论的人",不是"所有看的人"
2. **平台评论可被污染**——水军 / 引战 / 跑题评论会进数据。cheat-persona 聚类时对明显异常值降权,但不能完全过滤
3. **persona 滞后于真实受众变化**——画像基于过去 N 篇的评论。受众结构变了,要等新复盘累积才反映
4. **不解决"我想要的受众 ≠ 我实际的受众"**——persona 只如实报告"现在谁在看"。想转向另一种受众是选题战略问题,cheat-persona 只提供"现状 vs 目标"的 gap,不替你做战略
+452
View File
@@ -0,0 +1,452 @@
---
name: cheat-predict
description: 给最终稿写一份 immutable 盲预测日志。这是 cheat-on-content 整个校准循环的核心动作——预测段一旦写完不可改,由 hook 强制。**自动检测**:如目标文件已有 `## 预测` / `## 预测 v1` 段(被 cheat-shoot 调用走 v2 模式),改成 append `## 预测 v2` 而非覆盖。**打分通过 Task tool 委派给 `cheat-score-blind` sub-agent**context-isolated channel B),主 Claude review 后落盘。触发词:"启动预测"/"start prediction"/"给这稿子打分并预测"/"写预测日志"。
argument-hint: <script-path> [— mode: v1|v2] [— prediction-file: <path>] [— skip-blind]
allowed-tools: Bash(*), Read, Write, Edit, Glob, Task
---
# /cheat-predict — AI 主导的盲预测 + 用户 review
**这个工具是"作弊器"——AI 帮你做判断**。所以 cheat-predict 的核心是:
- **Claude 自己**读稿子 + 打 7 维分 + 给 bucket + 概率分布 + 反事实场景
- 用户 **review** 后回 "ok" 接受,或指出哪个维度 / 哪个判断不对
- 默认走快路径:用户直接 ok → 落盘
- 慢路径:用户挑刺某个维度 → Claude 改 → 再 review → 直至确认
不是用户从 7 维分到概率分布全部自己写,那 Claude 只剩"格式化器"——失去工具的核心价值。
**严格遵守 [shared-references/blind-prediction-protocol.md](../../shared-references/blind-prediction-protocol.md)**——见过任何后续数据就不能写预测,只能记 reconstructed。
完整组件清单见 [shared-references/prediction-anatomy.md](../../shared-references/prediction-anatomy.md)。
Confidence 派生表见 [shared-references/state-management.md](../../shared-references/state-management.md)。
## Overview
```
[用户:启动预测 scripts/<id>.md]
[Phase 0: blind check 自检] ← 触犯就拒绝
[Phase 0.7: 模式判定 — v1 (新建) 还是 v2 (append)]
[Phase 1: 读 script + rubric + state + 派生 confidence]
[Phase 2: **委派 cheat-score-blind sub-agent**Task tool)拿 9 维盲打 + per-dim confidence]
[Phase 2.5: 主 Claude 对 blind 输出做 review — 若任意维度 |delta| ≥ 2 vs 主估,弹给用户裁定]
[Phase 3: **Claude 自己**找锚点对比]
[Phase 4: **Claude 自己**给 bucket + 概率分布 + 中枢] ← confidence 低时分布更平
[Phase 5: **Claude 自己**写反事实场景 + 关键校准假设]
[Phase 5.5: **用户 review**——展示完整草拟版,等用户 "ok" 或挑刺]
├─ "ok" → Phase 6 落盘
└─ "X 维度应该 Y 不是 Z" → Claude 改 → 再 review → 循环
[Phase 6: 落盘 — v1 写新文件 / v2 append 到现有文件 ## 复盘 之前]
[Phase 7: 更新 state.in_progress_session]
```
## Constants
- **SCRIPTS_DIR = scripts/** — 草稿源目录
- **PREDICTION_DIR = predictions/** — 落盘目录
- **BLIND_CHECK = strict** — strict(默认)/ lenient(仅警告,不推荐)—— 跟 [blind-prediction-protocol.md](../../shared-references/blind-prediction-protocol.md) "见过数据"边界相关
- **BLIND_SCORING = on**(默认)/ off —— 是否走 [cheat-score-blind](../cheat-score-blind/SKILL.md) sub-agent。off 等价于 `--skip-blind` flag,标 `last_prediction_self_scored: true` 给 cheat-status 警告
- **DISAGREEMENT_THRESHOLD = 2** —— blind 与主 Claude 自评的单维度差异 |Δ| ≥ 此值 → Phase 2.5 弹用户裁定
- **BUCKET_PRESET = auto** — 自动派生:有 baseline_plays → 按 baseline × {0.3 / 1 / 3 / 10 / 30};无 baseline → 平台通用默认
- **MIN_ANCHORS = 2** — 锚点对比期望 2 个;不够时显式标"锚点 N/A"段(不删段,不省略)
> 💡 调用时覆盖:`/cheat-predict scripts/<id>.md — BLIND_CHECK: lenient` / `--skip-blind`(都不推荐)
## Inputs
| 必填 | 来源 |
|---|---|
| `<video-folder-path>``<script-path>` | 用户参数;缺失则询问 |
| `rubric_notes.md` | 用户项目根 |
| `.cheat-state.json` | 状态文件 |
| `predictions/*.md`(可选) | 历史预测,作为锚点 |
### 入参解析(Phase 0.5,在 blind check 之前)
用户给的路径**应该是** `scripts/<date>_<id>_<short>.md`。如不在 scripts/ 下:
| 形态 | 处理 |
|---|---|
| `scripts/<date>_<id>_<short>.md` | 标准路径,直接用 |
| `<id>``<short>` 简写 | glob `scripts/*_<id>_*.md``scripts/*<short>*.md` 找匹配 |
| 任意外部 .md 文件(如 `~/Desktop/my-draft.md` | **警告 + 询问**"建议把稿子放到 scripts/<date>_<id>_<short>.md 让 cheat-on-content 管理。要我帮你 cp 过去并算 id 吗?"用户同意 → 建标准路径再继续 |
| `videos/<id>/` 路径(user 误以为视频文件夹存稿子)| 提示"video folder 是拍后才建的——pre-shoot 草稿在 scripts/。你要 predict 哪份稿子?" |
如 scripts/<id>.md 不存在 → 报错并询问"你想 predict 的稿子在哪?"
## Workflow
### Phase 0: Blind check 自检(**最关键**,触犯立即终止)
按 [blind-prediction-protocol.md](../../shared-references/blind-prediction-protocol.md) 的"子 skill 必须做的检查清单"执行:
1. 询问用户该作品当前发布状态:
- 未发 → 通过
- 已发 < `RETRO_WINDOW_DAYS` 天 → 询问"你看过任何后续数据吗(播放/点赞/评论)?"
- 用户回答"没看过" → 通过,标记 `published_before_prediction: true` + `blind_status: confirmed_no_data_seen`
- 用户回答含糊 → 视为"已看",按下一项处理
- 已发 ≥ `RETRO_WINDOW_DAYS` 天 → **立即拒绝写"预测"**,建议改用 `_redo.md` 路径记 reconstructed retrospective
2. 自检对话历史里是否含播放/阅读/点赞/评论/转发等字眼的实际数字 → 命中则视为已见数据,按上面 strict 模式处理
3. `BLIND_CHECK=lenient` 模式:仅警告 + 强制在文件头标注 `**Reconstructed retrospective — NOT a blind prediction**`,但仍允许继续
通过 → 进入 Phase 0.7。
### Phase 0.7: 模式判定(v1 vs v2
判定本次是新建预测(v1)还是对既有预测的 v2 追加(拍后改稿场景)。
**显式参数优先**:用户/调用方传 `— mode: v2` + `— prediction-file: <path>` → 直接 v2 模式。
**自动检测**(无显式参数):
1. 推断目标 prediction 路径:`predictions/<同 scripts/<id> 命名>.md`
2. 读该路径:
- 不存在 → **v1 模式**,进入 Phase 1
- 存在但只有空 `## 复盘`(无任何 `## 预测...` 段)→ **v1 模式**(异常状态,覆盖警告 + 进 Phase 1)
- 存在且含 `## 预测``## 预测 v1` 段 → **v2 模式**
**v2 模式额外动作**
- 比较输入 script(最终拍摄稿)与 `## 预测` 段引用的原 `Script Hash`
- 如完全一致(hash 同)→ 警告"稿子没改,是否真要写 v2?"——用户确认才继续;不确认则退出
- 如不同 → 算 diff 概要(行数 / 字数 / 结构变化)→ Phase 5.5 review 时展示给用户
- 标记 `prediction_basis = "post_shoot_pre_publish"`v1 默认 `pre_shoot`
### Phase 1: 读最终稿 + rubric + state + 派生 confidence
1. 按 Phase 0.5 解析后的路径,读 `scripts/<id>.md` 全文
2. 计算 `script_hash` = sha256(script 内容)[:12] → header 用
3.`rubric_notes.md`,识别当前公式 + 维度(同 cheat-score Phase 2
4.`.cheat-state.json``rubric_version``content_form``calibration_samples``typical_duration_seconds``baseline_plays`
5. **从 `calibration_samples` 派生 confidence 等级**(按 [state-management.md confidence 表](../../shared-references/state-management.md))→ 后续写入 prediction header
6. 询问用户:"这是你打算实际拍摄发布的最终稿吗?还是会再改?"——必须是最终稿
7. 如果稿子字数与 `typical_duration_seconds` 派生范围严重不符(差 >50%)→ 提示用户:"这条稿子 N 字,按你设的典型时长(X 分钟)应该是 M-K 字。是临时改了时长,还是稿子需要砍/补?"
### Phase 2: 委派 cheat-score-blind sub-agent 拿盲打分
**BLIND_SCORING=on**(默认)—— 主 Claude 不再 inline 打分。通过 Task tool spawn `cheat-score-blind`,让一个 context-isolated 的 sub-agent 只看 script + rubric_notes.md 给出 N 维分。
详见 [cheat-score-blind/SKILL.md](../cheat-score-blind/SKILL.md) 的"主 Claude 调用契约"段。**Task prompt 必须精简**
```
Spawn cheat-score-blind sub-agent.
Input:
script_path: <Phase 0.5 解析出的 scripts/<id>.md>
rubric_notes_path: rubric_notes.md
Task: 按 rubric_notes 当前公式给上面 script 打分。返回严格 JSON(见 cheat-score-blind SKILL.md Phase 2 schema)。
不要读 state file / predictions/ / videos/ 任何其他文件。
不要询问用户 —— 你没有用户。
```
**调用前自检**:把 Task prompt 串过 `grep -Ei '播放|阅读|点赞|评论数|实际|retro|复盘|实绩|w$|万$'`——命中 → 改 prompt 重发。
**主 Claude 自己也内心估一份**(不发 sub-agent)——纯为 Phase 2.5 disagreement 检测,**不落盘**、**不替代 sub-agent 输出**。这个估值代表"如果我没用 sub-agent,我会打多少",是 contamination 的客观指标。
**沙盒 escape**`BLIND_SCORING=off``--skip-blind` —— 主 Claude 自己打 7 维。state 立刻标 `last_prediction_self_scored: true` + `last_self_scored_at: <ISO>`cheat-status 持续提示警告。仅用于:
- Task tool 不可用(开发环境 / 离线)
- 用户主动 audit 主 Claude 的 inline 判分能力(极少数)
按当前公式算 composite——**用 sub-agent 回传的 dim 分**,不用主 Claude 自估。
### Phase 2.5: Blind 输出 review + disagreement detection
拿到 sub-agent JSON 后,主 Claude 必做的事:
1. **JSON validity check**`python3 -c "import json; json.loads(...)"` 应能解析;不能解析 → 主 Claude 重发 Task(最多 3 次重试),仍败 → abort,向用户报告
2. **Contamination check**`self_check.any_contamination_signal == true` → 警告用户"sub-agent 自报疑似 contamination",但仍接受打分(confidence 降一档)
3. **Refusal check**`refusal != null` → 按 [cheat-score-blind/SKILL.md](../cheat-score-blind/SKILL.md) Phase 2 的处理表对应路径
4. **Disagreement detection**(核心):
- 主 Claude 内心估一份 N 维分(Phase 2 末尾的"自估"
- 对每个维度算 `delta = |主估 - blind|`
- 任何维度 `delta >= DISAGREEMENT_THRESHOLD`(默认 2**弹给用户裁定**
弹裁定 UX
```
⚠️ blind sub-agent 跟主 Claude 在某些维度差异较大:
| 维度 | blind (sub) | 主 Claude 自估 | delta | sub-agent 理由 |
|---|---|---|---|---|
| ER | 5 | 3 | 2 | "PPT加油猫猫开头—具象画面强" |
| AB | 2 | 4 | 2 | "一人公司视角,受众窄" |
谁更准?
a) 信 sub-agent(隔离打分,但同 Claude 模型)
b) 信主 Claude 自估(有更多对话上下文,可能是 contamination
c) 我自己定(你直接给分)
回 a / b / c <你的分数>
```
用户选:
- a → 用 sub-agent 全套分进 Phase 3
- b → 用主 Claude 自估全套分(视为有意接受 contamination)→ 强制标 `last_prediction_self_scored: true`
- c → 用户给的分覆盖该维度,其他维度仍走 sub-agent → 记到 `User Override`
**所有 delta** —— 即使全 < THRESHOLD —— 都记录到 prediction header 的 `BlindScore Disagreement` 字段(详见 [prediction-anatomy.md](../../shared-references/prediction-anatomy.md) 组件 1)。delta=0 也要记录。
### Phase 3: 锚点对比
**所有阶段都跑此 phase**——锚点不够时显式标 N/A,不删段。
1. Glob `predictions/*.md`,读每个文件 header(提取 composite、实绩 bucket、duration_seconds)。**注意排除 reconstructed predictions**(标记 "Reconstructed" 的不算锚点)
2. **优先**找同时长样本(`Target Duration (s)` 与本次差 ±20% 内)
3. 在同时长(或全部)池里,找 2-4 个 composite 与本次预测 ±0.5 范围内的样本
4. **如果池子太小**(同时长 < 2 个 + 全部 < 2 个)→ 输出"锚点对比 N/A 段"(参考 [prediction-anatomy.md](../../shared-references/prediction-anatomy.md) 组件 5)—— 仍写这段,告诉读者锚点为何缺
5. 列对照表;如跨时长,每行额外列"时长 vs 本次"列
6. **关键诊断**:如果某个锚点的 composite 几乎相同但实绩差异 ≥3x → 说明 rubric 没捕获关键维度。**在文件里明确标注**作为新观察的种子
> 为什么按时长筛锚点:4 分钟视频 5w 播放 vs 1 分钟视频 5w 播放完全不是一回事——长视频每秒扛了更多注意力损失。跨时长锚点容易得出虚假结论。
### Phase 4: Bucket + 概率分布 + 中枢
**所有阶段都写**——confidence 低时分布**更平**,不是省略。
1.`starter-rubrics/<content_form>.md` 读默认 bucket 边界(除非用户在 rubric_notes.md 自定义了)
2. 选择最可能的 bucketheadline call
3. **必须**给出所有 bucket 的概率分布——加起来 100%
4. **必须**给出该 bucket 内的"中枢"点估计
**反诚实陷阱**:如果你给一个 bucket 95% 概率,下次预测错了你没法说"我其实不太确定"。**真实的概率分布通常在 headline bucket 是 40-65%**,剩下 ≥35% 散布在邻近 buckets。
### Phase 5: 反事实场景 + 关键校准假设
**所有阶段都写**——校准池小时关键校准假设可能没有合适对照样本,那就写"无可对照样本——仍写下我对这次的核心赌注"+ 1-2 条这次想测的事。
**反事实场景**(4 段,每段对应一个可能的 bucket,写"如果落在这里,意味着什么 rubric 假设被验证 / 推翻"):参考 [prediction-anatomy.md](../../shared-references/prediction-anatomy.md) 组件 6。
**关键校准假设**(强烈推荐):
- 找一个对照样本(最好是上一篇预测)
- 明确写"我押本篇 vs 对照 = X 倍"
- 写"如果反过来 / 差距 < N → 哪个 rubric 假设被推翻"
`REQUIRE_HYPOTHESIS=required` → 缺失则不允许落盘。
### Phase 5.5: 用户 review**核心 — 决定写什么进文件**)
Phase 2-5 全部在内存里做完后,**一次性展示完整草拟版**给用户:
```
我的预测草稿(写文件前 review):
📊 7 维分(v0 / v2 / 等当前 rubric):
| 维度 | 分 | 理由 |
|---|---|---|
| ER | 5 | "PPT 加油猫猫 + 老板看到 + 大脑空白"——情感重 |
| HP | 5 | 开头"PPT 第七页大屏中央 一只加油猫猫"具体反差强 |
| QL | 5 | "加油猫猫救了我一命"双金句 |
| NA | 4 | 单一时间线 + 反思,清晰但不复杂 |
| AB | 4 | 一人公司题但 AI 焦虑普适 |
| SR | 3 | AI 焦虑是议题但不是热点对峙 |
| SAT | 2 | 共情调性,几乎无讽刺 |
→ composite ≈ 8.00
🎯 押 bucket30-100w,中枢 ~60w
概率分布: <5w 5% / 5-30w 22% / **30-100w 50%** / 100-150w 18% / >150w 5%
confidence: 🟢 中(基于 8 个校准样本,中枢 ±25%)
🔍 锚点对比:
| 对照 | composite | 实绩 | 异同 |
|---|---|---|---|
| ... | ... | ... | ... |
🤔 反事实:
如果 >100w → 验证 ER 主导假设强化
如果 30-100w → 基准线 ok
如果 <30w → 推翻 "AI 焦虑普适"AB 偏乐观
🎲 关键校准假设:本篇 vs [对照] 押 1.5x
——————————————————————————————
回 "ok" 我直接落盘,
或指出哪些维度 / 判断不对(如 "AB 给 3,太乐观" / "中枢应该 30w 不是 60w")。
```
用户三种回应:
1. **"ok"** / "可以" / "继续" → 直接 Phase 6 落盘,header 标 `Scored By: claude`
2. **"X 不对,应该 Y"** → Claude 改对应字段(不光改值,要更新 composite + 概率分布等连锁影响),重新展示 → 循环回 Phase 5.5
3. **"全部重做"** → Phase 2-5 重跑(罕见,通常是 Claude 严重误判稿子调性)
**用户挑刺的字段**记录到 prediction header 的 `User Override` 段(Phase 6 写入):
- 哪个维度 / 哪个数字被覆盖
- AI 原值 vs 用户改后的值
复盘时这个字段帮诊断:
- 用户每次都 ok(claude 一致)→ 没有用户偏见污染
- 用户经常覆盖某维度 → Claude 在该维度系统性偏离用户实际感觉
- 覆盖维度被实绩验证 → 用户直觉准 → rubric 可能漏了什么
**用户挑刺的纪律**
- 用户**只能改字段值**,不能在 review 阶段塞新理由让 Claude 重写整段——那是把 Claude 当代笔
- 改完 composite / 概率分布 / 锚点不一致 → Claude 自动连锁更新(不是用户算)
### Phase 6: 落盘
#### Phase 6a: v1 模式(新建预测文件)
文件名约定([blind-prediction-protocol.md](../../shared-references/blind-prediction-protocol.md) 的"文件名约定"段):
```
predictions/YYYY-MM-DD_<id>_<short-title>.md
```
- `YYYY-MM-DD`:今天日期(预测写下的日期)
- `<id>`:12 位 hash,对稿子全文做 sha256 取前 12 位(稳定 ID,重写不变)
- `<short-title>`3-8 字,去标点
**第一段标题写 `## 预测 v1`**(不再写裸 `## 预测`——为将来可能的 v2 留 schema 一致性。老用户的 legacy `## 预测` 文件不动,hook 都识别)。
**header 必填字段**
- `Article ID`(与 scripts/<id>.md 同 id
- `Script Path`(指向 scripts/<id>.md
- `Script Hash`Phase 1 算出的)
- `Calibration Samples` + `Confidence`(从 state 派生)
- `Prediction Basis``pre_shoot`v1 默认)
- `Scored By``claude` / `claude+user_override`
- **`BlindScored By`**`subagent-v1`Phase 2 默认)/ `main-claude-self``--skip-blind` 时) / `mixed`Phase 2.5 用户裁定 b/c
- **`BlindScore Disagreement`**JSON 字段列表,每维度 `{dim, blind, self, delta, decided_as}`,**所有维度必记**(即使 delta=0
- `User Override`(如有覆盖):列出哪些字段被用户改了
- 其他见 [prediction-anatomy.md](../../shared-references/prediction-anatomy.md) 组件 1
留一个空的 `## 复盘` 段:
```markdown
## 复盘
(待填——T+RETRO_WINDOW_DAYS 天后跑 /cheat-retro <对应 video folder>
```
#### Phase 6b: v2 模式(append 到既有文件)
**绝不**用 Write 覆盖文件——会被 immutability hook 拦。用 **Edit**`## 复盘` 之前插入 `## 预测 v2` 段:
```python
# 伪代码
edit_old = "## 复盘\n" # 单独一行,确保 hook awk 识别为 v1 段的边界
edit_new = """## 预测 v2 (replaces v1; basis=post_shoot_pre_publish)
**Diff vs v1**: 改了 N 行(X→Y%),主要变化:[摘要]
**重判触发**: cheat-shoot 检测稿子改动 ≥30%
**Script Hash (v2)**: <新稿子 hash>
[7 组件 — 与 v1 同 anatomy]
---
## 复盘
"""
```
v1 段**不动**。v2 段头部明确写"replaces v1"——读者一眼知道哪段是有效预测。
cheat-retro 复盘时按"读最后一个 `## 预测 vN`"逻辑,自然取到 v2 算偏差。
#### 共用规则
**所有阶段都用统一完整版格式**(参考 [prediction-anatomy.md](../../shared-references/prediction-anatomy.md) "完整结构总览")。confidence 低不缩格式,只让 header 标 confidence 等级 + 锚点对比段写"N/A 解释" + 概率分布更平。
写文件**前**自检 7 个组件齐全(缺锚点 / 关键校准假设 → 写"N/A 解释段",不删段)。
### Phase 7: 更新 state file
更新 `.cheat-state.json`
```json
{
"in_progress_session": {
"type": "prediction",
"file": "predictions/YYYY-MM-DD_<id>_<short>.md",
"video_folder": "videos/YYYY-MM-DD_<id>_<short>/",
"started_at": "<ISO timestamp>",
"rubric_version": "<v0/v2/...>"
},
"last_prediction_self_scored": <true --skip-blind / Phase 2.5 b>,
"last_self_scored_at": <ISO last_prediction_self_scored=true >
}
```
`video_folder` 为 null 表示用户跑的是裸 .md 文件,没建 video folder。
`in_progress_session``cheat-publish` 触发时清除。如果用户预测后从未 publish(弃稿),下次 `/cheat-init``/cheat-status` 检测到陈旧 in_progress 会询问是否清理。
`last_prediction_self_scored`
- `true` 仅当本次预测走了 `--skip-blind` 或 Phase 2.5 用户选了 b(信主 Claude 自估)
- 一旦 `true` → cheat-status 持续 nag"上次预测没走 blind sub-agent,已 N 天"——直到下次 normal `cheat-predict`(走 sub-agent)触发后才清回 `false`
- `last_self_scored_at` 跟随更新;下次 `cheat-predict` 走 sub-agent → 这两个字段一起被重置
### Phase 8: 控制台总结
**Cold-start-simple 模式**
```
✅ 预测落盘(cold-start 简化版):predictions/2026-05-04_a3f2c1d4e5b6_停止期待.md
7 维打分:ER5 / HP5 / QL4 / NA3 / AB5 / SR2 / SAT4
方向押注:比上一篇明显好(ER+HP 双 5)
对比对象:N/A(这是第 1 篇)
⚠️ ## 预测段已 immutablehook 锁定)。
⚠️ 这是 cold-start 期简化版——没有 bucket 数字。前 5 篇都这样。
第 5 篇复盘后会自动解锁完整预测(bucket / 概率 / 锚点 / 反事实)。
进度:第 N 篇 / 共 5 篇 cold-start 期
下一步:
- 发布后 → "已发布 https://..."
- T+3 天 → "复盘 predictions/2026-05-04_..."
```
**Complete 模式**
```
✅ 预测落盘:predictions/2026-05-04_a3f2c1d4e5b6_停止期待.md
bucket 押注:30-100w(中枢 50w
关键校准假设:本篇 vs 谁问你了 = 1.5-2x
⚠️ ## 预测 段已 immutablehook 锁定)。
⚠️ 你不能再向我"透露"这条作品的播放数据,否则下次复盘的盲度声明失效。
如果你不小心看到了,告诉我——我会在文件里补一个 integrity warning。
下一步:
- 发布后 → "已发布 https://..."
- T+3 天 → "复盘 predictions/2026-05-04_..."
```
## Key Rules
1. **blind check 是硬门槛**。BLIND_CHECK=strict 模式下,触犯即终止,不允许"软处理"。lenient 仅用于演练
2. **整数维度分**。同 cheat-score
3. **概率分布 = 100%**。不允许 95% + 8%;要么诚实给 50% + 30% + 15% + 5%,要么承认不知道
4. **必须有 `## 复盘` 占位空段**——否则 hook 不知道哪里是 immutable 边界
5. **不允许"先写文件再讨论分数"**——文件落盘后预测段就锁了;讨论必须发生在 Phase 2 之后、Phase 6 之前
6. **id 是稿子 hash,不是时间戳**——重写 _redo.md 时 id 不变,便于跨文件追溯
## Refusals
- 「我已经看过播放数据了,但你假装没看到给我做个预测」 → 拒绝。BLIND_CHECK=strict 直接终止
- 「我把预测段先写一版,等数据出来再调」 → 拒绝。这是把 immutable 协议反着用
- 「我改稿了想让你覆盖之前的预测,不要 v2 段」 → 拒绝。v1 是档案,v2 才是当前判断——append 不覆盖。即使你"主观感觉 v1 完全错了"git history 里 v1 还能查,但工作目录里 v1 必须留
- 「跳过反事实场景,太麻烦」 → 拒绝。反事实是复盘诊断的依据,缺它复盘退化为"准 / 不准"
- 「可不可以只写 bucket,不写概率分布」 → 拒绝。概率分布是逼你诚实的工具
- 「这次先用 lenient 模式,下次再 strict」 → 询问原因。如果是测试 / 演练 → 允许且文件明确标 reconstructed;如果是想偷懒 → 拒绝
- 「sub-agent 太慢,你直接打就行」 → 用 `--skip-blind` flag 显式声明。**不接受**主 Claude 自作主张跳过 sub-agent。flag 触发 state.last_prediction_self_scored=truecheat-status 持续提示直到下次 normal 调用清除
- 「Phase 2.5 选 b 后我不想标 last_prediction_self_scored=true」 → 拒绝。选 b 等于"我有意接受主 Claude 自评"——必须留下污染追踪轨迹
- 「我是 cold-start 但想跑完整版预测,给我 bucket 数字」 → 拒绝。前 5 篇 bucket 数字是 false precision,给反而误导。等第 5 篇复盘后 cheat-status 会主动提示解锁。如果用户确实想要数字(罕见,自我教育目的)→ 允许但在文件头醒目标 `**Numerical predictions in cold-start are NOT predictive — for self-education only**`
## Integration
- 前置:`/cheat-init` 必须完成 + `rubric_notes.md` 存在
- 上游可选:`/cheat-score` 反复尝试不同稿子版本
- 下游:`/cheat-publish`(发布登记)→ `/cheat-retro`(复盘)→ 累计 ≥ MIN_SAMPLES 后 `/cheat-bump`
- hook 依赖:`hooks/prediction-immutability.sh` 必须已安装在用户 `.claude/settings.json`,否则 immutability 仅靠 SKILL.md 自律——`cheat-status` 会持续提示
+170
View File
@@ -0,0 +1,170 @@
---
name: cheat-publish
description: 登记一篇内容已发布,把 URL/平台 ID/发布时间写入对应预测文件 header 和 state file。这是一个轻量动作——只更新元数据,**不动预测段任何字符**。触发词:"已发布"/"I shipped"/"发布链接是 X"/"刚发完 [url]"/"publish registered"。
argument-hint: <prediction-file-or-url> [— platform: youtube|bilibili|douyin|...]
allowed-tools: Bash(*), Read, Edit, Glob
---
# /cheat-publish — 发布登记
把作品的发布元数据(URL、发布时间、平台)补到预测文件 header 与 state file。**禁止改预测段**——hook 会拦。
## Overview
```
[用户:已发布 https://...]
[Phase 0: 找到对应的预测文件] ← 通过 in_progress_session 或匹配
[Phase 1: 解析 URL → 平台/发布时间]
[Phase 2: 更新 prediction 文件 header(仅 metadata 段)]
[Phase 3: 更新 .cheat-state.json,清除 in_progress_session]
```
## Constants
- **AUTO_DETECT_PLATFORM = true** — 从 URL 模式自动识别平台
- **VERIFY_BLIND = true** — 提醒用户:从此刻起看到任何后续数据都会破坏盲度声明的诚信
## Inputs
| 必填 | 来源 |
|---|---|
| `<prediction-file>` 或 URL | 用户参数;缺失则用 `.cheat-state.json``in_progress_session.file` |
| `.cheat-state.json` | 用户项目根 |
## Workflow
### Phase 0: 找到对应的预测文件
按优先级:
1. 用户参数明确给了 prediction 文件路径 → 用它
2. 用户参数只给了 URL → 读 `.cheat-state.json``in_progress_session.file`
3. 都没有 → 列出 `predictions/*.md` 中 header 没填 `published_at` 的文件,让用户选
**警告路径**:若 `in_progress_session.file` 与用户给的 URL 时间差超过 14 天 → 提示"这个预测写于很久之前,确认是这篇?"
### Phase 1: 解析平台
`AUTO_DETECT_PLATFORM=true` 时按 URL 模式:
| URL 模式 | 平台 |
|---|---|
| `youtube.com/*` `youtu.be/*` | youtube |
| `bilibili.com/*` `b23.tv/*` | bilibili |
| `douyin.com/*` `iesdouyin.com/*` `v.douyin.com/*` | douyin |
| `xiaohongshu.com/*` `xhslink.com/*` | xhs |
| `mp.weixin.qq.com/*` | wechat |
| `substack.com/*` `*.substack.com/*` | substack |
| `medium.com/*` `*.medium.com/*` | medium |
| `twitter.com/*` `x.com/*` | twitter |
| 其他 | unknown — 询问用户 |
发布时间获取:
- 不强求自动抓——绝大多数平台需要登录态
- 询问用户:"发布时间是?(默认:现在)" → 接受 ISO 8601 或自然语言("今天 14:30" / "20 分钟前"
- 解析失败 → 用 now()
### Phase 2: 更新 prediction 文件 header
**绝不**触碰 `## 预测` 段及之后。只动文件最顶部的 metadata 块。
读文件,定位到 metadata 块(在第一个 `##` 之前的所有行)。检查是否已有这些字段——有则警告"已登记过"并询问是否覆盖;无则追加:
```markdown
**Published at**: 2026-05-04T14:32:00+08:00
**Platform**: douyin
**URL**: https://v.douyin.com/abc123
**Video Folder**: videos/2026-05-04_a3f2c1d4_停止期待/
**Aweme ID**: 7234567890123456789 (douyin / 视频号 等需要的 platform-specific ID)
```
**关于 platform-specific ID**
- 抖音:从 URL 短链 resolve 后提取 `aweme_id`v.douyin.com → 重定向后含 modal_id 或 item_id 参数)
- B 站:BV 号
- 小红书:note_id
- YouTubev= 参数后的 video_id
如果用户给的是分享短链(无法立刻 resolve)→ 标 `Aweme ID: pending`,下次 `/cheat-retro` 时由 adapter 解析。
**video folder 处理**:到 cheat-publish 这一步,对应的 `videos/<id>/` 目录**应该已经由 cheat-shoot 创建**(含 script.md)。
- 如 video folder 不存在 → 警告"你跳过了 cheat-shoot?建议先跑 cheat-shoot 把拍摄稿登记进 video folder 再发"**询问用户是否跳过登记直接发**:
- 是 → 自动建一个 video folderfallback),但不询问稿子一致性,标 `ad_hoc_publish: true`
- 否 → 让用户先跑 cheat-shoot 再回来 publish
用 Edit 工具(不是 Write 重写整个文件)。
**hook 行为预期**:因为只动 metadata 段(在 `## 预测` 之前),immutability hook 应放行。如果 hook 误拦 → 报告 bug**不要绕过 hook**。
### Phase 3: 更新 state file
```json
{
"in_progress_session": null,
"last_published_at": "<ISO timestamp>",
"last_published_file": "predictions/<filename>",
"last_published_video_folder": "videos/<...>/",
"last_published_platform_id": "<aweme_id 或 BV 号 等>",
"pending_retros": [
"predictions/<filename>"
],
"shoots": [
// 移除 video_folder 匹配本次发布的项;buffer -1
]
}
```
**`shoots` 队列处理**(buffer 跟踪关键):
1. 读 state.shoots[]
2.`video_folder == 本次发布的 video_folder` 的项 → 移除
3. 如果没找到 → 警告"buffer 队列里没有这条视频。是直接发布没经过 /cheat-shoot 吗?"——不阻塞,但提示用户下次走 /cheat-shoot 让 buffer 跟踪准确
`last_published_platform_id` 是 cheat-retro 调 adapter 时的输入——如 douyin-session 需要 aweme_id 直接抓数据。
`pending_retros` 是待复盘列表——`cheat-status` 会基于这个列表 + RETRO_WINDOW_DAYS 显示"今天该复盘哪些"。
### Phase 4: 提醒 + 下一步 + buffer 状态
```
✅ 登记完成:predictions/2026-05-04_a3f2c1d4e5b6_停止期待.md
- Published at: 2026-05-04 14:32
- Platform: douyin
- URL: https://v.douyin.com/abc123
📦 Buffer:N 篇(颜色 + 含义)
按你的 cadenceX= N×X 天 buffer
[如颜色变了,提示"现在该去拍/暂停拍"]
⚠️ 从此刻起,你看到任何关于这条作品的播放/点赞/评论数据
都会破坏盲度声明的诚信。如果不小心看到,告诉我——
我会在文件里追加一个 integrity warning。
📅 计划复盘:T+3d,约 2026-05-07
到时间说:"复盘 predictions/2026-05-04_..."
```
Buffer 颜色由 [shared-references/cadence-protocol.md](../../shared-references/cadence-protocol.md) 派生。如本次发布让 buffer 跌入红色(断更风险)→ 高亮警告"今天必须再拍 ≥1 条"。
## Key Rules
1. **不动预测段**。即使是修复笔误,也不允许在 publish 时改预测段
2. **不抓数据**。publish 是登记动作,不是数据回收(那是 cheat-retro 的活)
3. **state 字段名固定**`pending_retros` / `last_published_at` 是其他子 skill(特别是 cheat-status / cheat-retro)依赖的契约
4. **平台未知不强报**。无法识别 → 询问用户,允许 `platform: other` 作为兜底
5. **重复登记需明示**。已有 published_at → 询问"覆盖?",绝不静默覆盖
## Refusals
- 「我顺手把预测段也改一下」 → 拒绝。请走 `_redo.md` 路径
- 「URL 我等会儿补,先把发布时间记上」 → 允许:URL 字段可后续追加;published_at + platform 必填
- 「跳过 metadata 更新,直接清 in_progress_session」 → 拒绝。元数据是复盘时的关键上下文(特别是 platform 决定数据回收用哪个 adapter)
## Integration
- 上游:`/cheat-predict`(写出 prediction 文件并设 in_progress_session
- 下游:T+RETRO_WINDOW_DAYS 后 → `/cheat-retro`
- `cheat-status``pending_retros` 字段计算"今天该复盘哪些"
- 平台字段被 `cheat-retro` 用来路由到对应的 perf-data adaptermanual-paste / youtube-data-api / 等)
+235
View File
@@ -0,0 +1,235 @@
---
name: cheat-recommend
description: 从 candidates.md 里按当前 rubric 排序推荐 top N 选题,每条带 composite + 一句 rationale + 锚点对比。**candidates 不存在时给引导而非报错**。触发词:"推荐选题"/"next topic"/"下一篇做什么"/"recommend topics"/"挑一个选题"。
argument-hint: [— top: N] [— filter: tier1|all|safe|risky]
allowed-tools: Read, Glob, Grep
---
# /cheat-recommend — 候选池排序推荐
读 candidates.md → 按 composite 排序 → 输出 top N 推荐,每条带评分细节 + 锚点对比 + 推荐理由。
## Overview
```
[用户:推荐选题]
[Phase 0: 检查 candidates.md 存在性] ← 不存在则引导,不报错
[Phase 1: 解析 candidates 列表]
[Phase 2: 过滤(tier / 安全性 / 已发过)]
[Phase 3: 排序 by composite + 找锚点]
[Phase 4: 输出 top N + 每条的 rationale + 锚点对比]
```
## Constants
- **TOP_N = 5** — 默认推荐 top 5
- **STRATEGY = stable+experimental** — 推 ≥2 时按 [cadence-protocol.md](../../shared-references/cadence-protocol.md) 的"1 稳分 + 1 实验性"策略;推 1 时只推 top 稳分
- **POOL_PATH = candidates.md** — 候选池路径
- **EXCLUDE_PUBLISHED = true** — 排除已发布的(与 `predictions/*.md` 去重)
- **EXCLUDE_REJECTED = true** — 排除用户主动跳过的(`tier=skip`
- **REQUIRE_SCORED = true** — 只推荐已打分的——避免推没读过的素材
- **DUPLICATE_CATEGORY_LOOKBACK** — 派生自 `state.target_publish_cadence_days`max(3, cadence_days × 3) 天内已发同类目候选不推(避免审美疲劳)
> 💡 调用时覆盖:`/cheat-recommend — top: 3 — filter: safe`
## Inputs
| 必填 | 来源 |
|---|---|
| `candidates.md` | 用户项目根 |
| `predictions/*.md` | 用于去重 |
| `.cheat-state.json` | 当前 rubric_version |
## Workflow
### Phase 0: 候选池存在性检查
`candidates.md`
| 状态 | 处理 |
|---|---|
| 文件不存在 | **不报错**。输出引导:见下方"无候选池引导" |
| 文件存在但空(< 1 个 entry | 同上 |
| 文件存在且非空 | 进入 Phase 1 |
**无候选池引导**(核心:不让用户第一次遇到 cheat-recommend 时被劝退):
```
你目前没有候选池(candidates.md 不存在或为空)。
绝大部分人没有候选池——这很正常。四个建立方式,挑一个:
1. 🌱 [推荐] 跑 /cheat-seed
一次性的种子动作:3 个问题(兴趣 / 调性 / 红线)→ 拉公开热点 + Claude brainstorm
→ 输出 15 候选让你挑 5 → 默认顺带写 5 个 draft。5 分钟搞定。
- 没发过历史的:纯 brainstorm(兴趣 × 热点)
- 发过历史的(init 时已 import):brainstorm 会基于"你过去做过什么"给推荐
说:"找选题" 或 "seed"
2. 🔥 [日常补充] 用 /cheat-trends 抓 20 条带打分的候选
说:"抓热点" — 从 weibo-hot / zhihu-hot / b站热门 / HN / 你配的源各拉 N 条
适合已经跑过 /cheat-seed、想日常补充候选池的用户
3. ✍️ 手动建:把候选标题贴进 candidates.md,每行一条
我会自动给每条粗打分
4. 📋 从 Notion / RSS 导入:跑 /cheat-init --mode add-pool 配置 adapter
你也可以跳过候选池,直接给我具体稿子说"启动预测"。
> /cheat-seed vs /cheat-trends 的区别:
> - seed 是种子动作(含 brainstorm + 可选 draft),适合"我从零开始没选题"
> - trends 是日常多 adapter 抓取(不 brainstorm 不写 draft),适合"日常补充候选池"
```
完成引导 → 退出,不继续后续 phase。
### Phase 1: 解析 candidates
按 [candidate-schema.md](../../shared-references/candidate-schema.md) 的"Markdown 表示"格式解析每个 H3 entry
```markdown
### [tier1] 标题
- **id**: a3f2c1d4e5b6
- **composite (v2)**: 8.47 — ER=4 HP=4 QL=5 NA=3 AB=5 SR=3 SAT=3
- **predicted bucket**: 5-30w
...
```
提取每条的 `id` / `title` / `tier` / `composite` / `dimension_scores` / `note`
容错:`candidates.md` 格式被用户手改过 → 询问用户 schema,**不要静默忽略不识别的 entry**。
### Phase 2: 过滤
```
1. EXCLUDE_PUBLISHED=true → 扫 predictions/*.md 的 header,提取所有 id;从候选池过滤掉
2. EXCLUDE_REJECTED=true → 过滤 tier=skip
3. REQUIRE_SCORED=true → 过滤 composite=null(未打分的不推荐)
4. filter 参数:
- tier1: 只保留 tier=tier1
- all: 不过滤(tier1+2+3
- safe: 排除 tier=risky
- risky: 仅显示 tier=risky(用于"我今天就想发风险议题")
```
### Phase 2.5: Buffer 颜色覆盖(**最高优先级**)
`state.shoots` + `state.target_publish_cadence_days` 算 buffer 颜色([cadence-protocol.md](../../shared-references/cadence-protocol.md)):
| Buffer 颜色 | 推荐策略覆盖 |
|---|---|
| 🔴 红 | **只推 top 1 稳分**——不推实验性。回:"buffer 已 0/1 篇,下个发布日断更风险高,今天必须拍 ≥1 条稳分。下面是 top 1 稳分(不推实验性)" |
| 🟠 橙 | 标准 1 稳 + 1 实验,但提示"建议优先拍稳分" |
| 🟢 绿 | 标准 1+1(默认) |
| 🔵 蓝 | **拒绝推荐**。回:"你 buffer 已 N 条,cadence-protocol 规定积压时暂停拍摄。先发存货 + 复盘。手动覆盖请说 '我就要拍'" |
| 灵活模式 (`target_publish_cadence_days=null`) | 不应用 buffer 覆盖,标准策略 |
### Phase 3: 排序 + 选 1 稳 + 1 实验(按 STRATEGY
#### 第 1 条(稳分)
1.`composite` 降序排
2. 过滤掉 `tier=risky`(稳分要安全议题)
3. 过滤掉 `category` 与最近 `DUPLICATE_CATEGORY_LOOKBACK` 天已发/已推过的重复(避免审美疲劳)
4. 取 top 1
#### 第 2 条(实验性)
1. 在 candidates.md 中找:
- 维度组合与最近已发样本**差异最大**(增加校准信息量),或
- 含明确的 pattern/dimension hypothesis(如 "MS=5 的 A/B 对照"),或
- tier=risky 但用户主动愿意试(用 `--filter risky` 覆盖)
2. composite 不一定 top——但有"信息价值"
3. 如 candidates 池里没有合适的实验性候选 → 回:"候选池里没有明显的实验性样本,给你 2 条稳分"
#### 剩余 (TOP_N - 2) 条
按 composite 降序补满,标 "(备选)"。
#### 锚点
对每条找 1-2 个 composite 接近的**已发布**作品作为锚点(从 `predictions/*.md` 读)。优先**同时长**锚点(按 `state.typical_duration_seconds` ±20%)。
### Phase 4: 输出
```
🎯 候选池推荐(rubric: v2 / buffer: 🟢 绿 / cadence: 隔日更)
📌 第 1 条 — **稳分**(推荐立即拍):
**[tier1] [👍 9.18] "为你好"高密体系**
- 维度:ER=5 HP=5 QL=4 NA=4 AB=5 SR=5 SAT=4
- 粗预测桶:30-100w(中枢 ~60w
- rationaleER+SR 双 5 顶配,"高密度家庭议题"普适且分享安全
- 锚点:仓鼠 (composite 9.41, 实绩 124w) — 同走"理论框架+具象样本"路线
- 风险:议题厚重,不适合连续 2 篇都打这种
🧪 第 2 条 — **实验性**(验证特定假设):
**[tier1] [👍 8.71] 哈哈长度**
- 维度:ER=3 HP=5 QL=5 NA=4 AB=5 SR=4 SAT=5
- 粗预测桶:30-100w(中枢 ~55w
- **测试目标**v2.1 候选维度 MS+TS 双 5 vs 谁问你了同 ER/HP/QL/SR 但 MS+TS 低 3
- 信息价值:拍这条能强证据/弱推翻 v2.1 升正
- 锚点:谁问你了 (composite 8.24, 实绩 11.7w)
(备选 top 3):
3. ……
4. ……
5. ……
下一步:
- 选稳分 + 实验性各拍 1 条 → 改写 script → "启动预测"
- 只拍 1 条 → 选稳分(buffer 颜色越红,越应该选稳分)
- 想抓更多候选 → 说"抓热点"
- 都不满意 → 说"过滤改 all"看其他 tier 或 "regen"
```
如 buffer 颜色为 🔴:
```
🔴 buffer 警戒:你 buffer 已 0/1 篇,**下个发布日可能断更**。
按节奏协议,只推 top 1 稳分(不推实验性):
**[tier1] [👍 9.18] "为你好"高密体系**
- ...(同上稳分格式)
今天必须拍这条。挑 5 条候选 → "抓热点"。
```
如 buffer 颜色为 🔵:
```
🔵 buffer 积压:你 buffer 已 N 条,**暂停推荐**。
按节奏协议,先发存货 + 复盘。
- 已拍未发:N 条(最早一条 X 天前拍的)
- 待复盘:N 条
说 "已发布 ..." 出队,或 "复盘" 处理待复盘项。
如果你坚持要拍新的,回 "我就要拍",我会推 top 1 稳分。
```
每条必有:维度评分(让用户能挑战打分)+ 锚点(让用户校准 composite 的可信度)+ rationale(让用户理解推荐逻辑)。**不允许只输出 composite 排序而无解释**——那是黑箱。
## Key Rules
1. **不报错,给引导**。candidates 缺失是默认状态,不是错误
2. **不推未打分的**。REQUIRE_SCORED=true 是诚实门槛——推未读过的素材是占星
3. **必带锚点**。composite 8.47 在不同账号意味不同,锚点把抽象数字 ground 到真实样本
4. **必带 rationale**。一句话——为什么这条比第二条强?
5. **去重 published**。已发过的不推(用户可显式覆盖)
## Refusals
- 「直接给我 composite 最高的,不用解释理由」 → 拒绝。展示评分 + 锚点是发现"打错"的唯一机会
- 「把 candidates.md 里所有 entry 都重新打分一遍」 → 路由到 `/cheat-score` 单条做;批量重打分是 `/cheat-bump` 的一部分,不在 recommend 范围
- 「按预测桶排,不要按 composite」 → 询问理由。bucket 是 composite 的离散化,按 composite 排即按 bucket 排,差异在桶内序——如果用户真想按"押注期望值"排,需要乘以平均播放,那是另一个独立 scoring 维度
## Integration
- 上游:`/cheat-trends` 把外部热点拉进 candidates.md → recommend 自动看到
- 下游:用户挑一条后写稿 → `/cheat-predict`candidate 的粗 composite 不进入 predictionprediction 重新打)
-`/cheat-status` 协调:status 显示 "candidates 池有 N 条 tier1 未发"recommend 提供具体推荐
+372
View File
@@ -0,0 +1,372 @@
---
name: cheat-retro
description: T+N 天数据回收 + 复盘 + 把实绩观察写入 rubric-memo.md。这是校准循环的反馈环节——不复盘的预测等于占星。触发词:"复盘 [path]"/"retro this"/"T+3d 数据来了"/"抓数据 [path]"/"把这篇复盘了"。
argument-hint: <prediction-file> [— window: 3|5|7] [— source: manual|adapter]
allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep, Skill
---
# /cheat-retro — 数据回收与复盘
抓 T+N 天的实际表现 → 对比预测 → 提炼新观察 → 写入 rubric-memo.md。**只追加 `## 复盘` 段,绝不改预测段**。`rubric_notes.md` 是 blind 白名单,只能保存通用公式、维度定义和抽象规则,不能写入样本名、实绩、评论、链接或播放/阅读数。
## Overview
```
[用户:复盘 predictions/2026-05-04_...]
[Phase 0: 校验 immutability + 校验时间窗口]
[Phase 1: 抓数据(manual paste 或 adapter]
[Phase 2: 写实绩段 + top 评论关键词]
[Phase 3: 验证/推翻预测的各假设]
[Phase 4: 提炼新观察]
[Phase 5: 落盘(追加到 ## 复盘 段)]
[Phase 6: 写入 rubric-memo.md 的"观察记录"段]
[Phase 7: 检测是否触发 bump 候选 → 提示用户跑 /cheat-bump]
```
## Constants
- **RETRO_WINDOW_DAYS = 3** — 默认 T+3d。短视频快平台可设 1,长文设 7
- **DATA_SOURCE = manual** — manual: 用户粘数字;adapter: 调对应平台 adapter(需配置)
- **AUTO_PROPOSE_BUMP = true** — Claude 判断是否系统性偏差时自动提议 /cheat-bump
- **默认参考**:连续 ≥3 次同向偏差(high/low)→ 提议
- **但 Claude 可以更早提议**:1 次极端偏差(如中枢 50w 实绩 5w 这种 ≥10x),即使没有"连续"也提议
- **也可以更晚**:3 次同向但每次偏差都很小(<25%),可能只是噪声不是系统性
- **TOP_COMMENTS_N = 20** — 抓 / 粘 top N 高赞评论
> 💡 调用时覆盖:`/cheat-retro <file> — window: 7 — source: adapter`
## Inputs
| 必填 | 来源 |
|---|---|
| `<prediction-file>``<video-folder>` | 用户参数;缺失则从 `.cheat-state.json``pending_retros[0]` |
| `rubric_notes.md` | 用户项目根(只读,用于当前规则上下文;不得写入实绩观察) |
| `rubric-memo.md` | 用户项目根(写入复盘观察、实绩证据、样本名与评论信号) |
| `.cheat-state.json` | 状态文件 |
### 入参解析(同 cheat-predict 双形态接受)
用户给的可能是:
- **`predictions/2026-05-04_<id>_<short>.md`** → 直接用这个 prediction 文件
- **`videos/2026-05-04_<id>_<short>/`** → 找对应的 prediction 文件(按 id 匹配)+ 把 report.md 写到该 video folder 里
- 缺省 → 从 `pending_retros[0]` 取最早的
## Workflow
### Phase 0: 校验
1.`<prediction-file>`,确认存在
2. **识别有效预测段**:扫所有 `## 预测...` 段(可能含 `## 预测``## 预测 v1``## 预测 v2` 等):
- 取**最后一个**`## 预测 vN` 作为本次校准的依据(v2 存在则用 v2;只有 v1 则用 v1;legacy 单段 `## 预测` 直接用)
- state.shoots 对应项的 `v2_prediction_written` 应与"是否存在 v2 段"一致——不一致则警告(state 与文件脱节)
3. **校验 immutability**:在内存 cache 住所有 `## 预测...` 段的内容(用于 Phase 5 后核对——**全部段不可改**,不只是有效段)
4. 校验文件 header 有 `Published at` → 没登记的不能复盘,提示用户先 `/cheat-publish`
5. 校验时间窗口:今天 - published_at >= RETRO_WINDOW_DAYS。不够 → 提示"还差 X 天",询问用户是否仍坚持复盘(标 `early_retro: true`
6. 校验已有复盘段是否已填——已填则询问"是补充还是修正?"
- 补充 → 在已有复盘段下追加新子段,标日期
- 修正预测段(用户错觉)→ 拒绝
### Phase 1: 抓数据
`state.data_collection` 字段分两条路径——抓回数据后**写到 video folder 的 `report.md`**(如果 prediction 关联 video folder),同时解析摘要 inline 到 prediction 的复盘段。
#### Path A`DATA_SOURCE=manual`(候补方案)
- 询问用户:"粘贴这条作品的当前数据:播放 / 点赞 / 评论 / 转发 / 收藏(顺序无所谓,能识别就行)"
- 用户粘 → 解析提取数字
- **强制要求 top 评论**:让用户从平台后台或直接打开评论区贴 TOP_COMMENTS_N 条到对话里(每条带赞数)
- 用户拒绝 / 给少于 5 条 → **拒绝继续**:"评论才是真信号——'她不一样'这种模因爆发只能从评论看出。
没评论的复盘 = 看体温计判断病情。粘 top 20 给我。如果实在拿不到,告诉我原因(比如评论被关了),我帮你标 `comments_unavailable`,但这次复盘价值打折。"
- 把粘的原始数据写到 `videos/<...>/report.md`(如有 video folder
#### Path B`DATA_SOURCE=adapter`
按 prediction header 的 `Platform` 字段 + state 的 `enabled_perf_adapters` 决定调哪个:
| Platform | Adapter | 调用方式 |
|---|---|---|
| `douyin` | `adapters/perf-data/douyin-session/` | `bash <adapters-dir>/douyin-session/run.sh <aweme_id> <video_folder>` |
| `xhs` | `adapters/perf-data/xhs-explore/` | `bash <adapters-dir>/xhs-explore/run.sh <note_id> <video_folder>` |
| `linkedin` | `adapters/perf-data/linkedin-session/` | `bash <adapters-dir>/linkedin-session/run.sh <activity_id> <video_folder>` |
| `youtube` | `adapters/perf-data/youtube-data-api/`planned — batch 3, not yet available | 调 YouTube Data API(需 API key |
| `bilibili` | `adapters/perf-data/bilibili-stat/` | `bash <adapters-dir>/bilibili-stat/run.sh <bvid> <video_folder>` |
| 其他 | 无 adapter | 优雅降级到 Path A |
> `<adapters-dir>` = 克隆源码处的 `cheat-on-content/adapters/perf-data/`install.sh **不**复制 adapter 到 ~/.claude/skills,只复制 15 个 skill)。定位:`find ~ -path '*/cheat-on-content/adapters/perf-data' -type d | head -1`。
**douyin-session 的特殊处理**
- 视频 URL(如 `https://v.douyin.com/abc123`)→ 短链解析 → 提取 aweme_id
- 调用前确认 cookie 文件存在(adapter 会找 `.auth/`);不存在则提示用户先跑 `python <adapter>/crawler.py login`
- adapter 输出在 `<video_folder>/report.md`adapter 的 renderer.py 已经按这个格式写)
- cheat-retro 读这个 report.md 解析关键数据 → 摘要写入 prediction 的复盘段
**xhs-explore 的特殊处理**
- 笔记 URL`https://www.xiaohongshu.com/explore/<note_id>?xsec_token=...``https://xhslink.com/xxx`)→ 提取 note_id
- 调用前确认 cookie 存在(adapter 找 `.auth-xhs/`);不存在则提示先跑 `python <adapter>/crawler.py login`
- 字段已校准(观看 `view_count` 等已写死);万一接口改版导致某项为 0,看 report.md 末尾 galaxy 原始 JSON,把新 key 加进 `crawler.py``_normalize_note`
- **评论可能抓不到**xsec_token 缺失 / 评论关闭)→ report.md 标"未抓到评论" → 此时**降级要求用户 manual 粘 top 20 评论**(评论是真信号,不能省)
**linkedin-session 的特殊处理**
- 帖子 URL`https://www.linkedin.com/feed/update/urn:li:activity:<id>/`)或裸 activity_id → adapter 自动提取 activity_id
- 调用前确认 cookie 存在(adapter 找 `.auth-linkedin/`);不存在则提示先跑 `python <adapter>/crawler.py login`
- **只能抓你本人发的帖子**(LinkedIn 单帖分析仅作者可见);LinkedIn 界面 日/英 随机切换,`extract.py``POST_METRICS` 已存双语标签,万一某项为 None 看 `.cheat-cache/linkedin-session-debug/post_<id>.txt` 把新标签补进去
- **评论只给数、不给正文**(分析页限制)→ report.md 标注 → **降级要求用户 manual 粘 top 评论**(评论是真信号,不能省)
**bilibili-stat 的特殊处理**
- 视频 URL`https://www.bilibili.com/video/<BV号>` 或 b23.tv 短链)或直接给 BV 号 → adapter 自动提取 BV 号
- **无需登录**B站视频数据(view)与评论(reply)都是公开接口、免 wbi 签名,adapter 是纯 httpx,没有 `crawler.py login` 步骤、不碰 `.auth/`
- 依赖 httpx:首次用 `pip install -r <adapter>/requirements.txt`
- 评论按热度(sort=2)抓取;B站老接口主楼评论可能偏少,不足时降级 manual 粘
**任何 adapter 失败**cookie 过期 / 接口变化 / 网络)→ **优雅降级到 manual**,提示用户:"adapter 调用失败,原因 [X]。改用 manual 模式——粘下面的数据"。**不阻塞流程**。
#### 共同输出
不管 Path A 还是 B,最终:
- `videos/<...>/report.md` 含完整原始数据(数字 + top 评论)
- prediction 文件复盘段含**摘要**(关键比率 + 评论关键词聚类 + 验证/推翻判定)
- report.md 是数据真相,prediction 复盘段是判断真相
### Phase 2: 写实绩段 + top 评论分析
**实绩数据格式**(参考 [prediction-anatomy.md](../../shared-references/prediction-anatomy.md) 的复盘段格式):
```markdown
### 实绩数据
- 播放:71.1w(落在 `30-100w` 桶内偏高,相对中枢 50w **+42%**
- 点赞:2.4w(赞播比 3.38%
- 评论:899(评播比 0.126%
- 收藏:5251
- 分享:1.8w(分播比 2.53%,强)
```
数据点之间的派生比率(赞播比、评播比、分播比)必须算出来——它们是单纯播放数无法暴露的信号。
**top 评论关键词聚类**
- 把粘进来的 N 条评论分 3-5 类(高赞模因 / 概念引用 / 离题噪声 / 转发暴露暗示 / @朋友传播 等)
- 每类列代表性评论(带赞数)
- 报告比例("22% 是模因复用、35% 是概念引用、5% 是离题")
### Phase 3: 验证/推翻
对 prediction 文件里的每一项(推理因素表、关键校准假设、反事实场景),逐项判定:
```markdown
### 哪些预测被验证 ✅ / 推翻 ❌
**验证 ✅**:
- 关键校准假设完全成立:本篇 71.1w / 谁问你了 11.7w = 6.07x,远超我押的 1.5-2x
- ER=5 主导情感传播力 → H1 强证据
- HP=5 验证:分播比 2.53% 与"金句被高频引用"匹配
**推翻 ❌**:
- 中枢 50w 被超出 +42%
- 反事实推理里"必须搭配强社会议题才能破 30w" 完全错误
- SR 押注("H2 SR 应上调")反向被推翻:SR 在情感向场景几乎不贡献
```
**关键纪律**
- 每条验证 / 推翻必须引用具体数据("分播比 2.53%"),不许写"基本符合"这种含糊措辞
- 反事实的"如果落在 X bucket 意味着什么"——实际落在的那个 bucket 直接告诉你哪个 rubric 假设被测试了,明确写出来
### Phase 4: 提炼新观察(**两类,分别写入两个文件**)
#### 4a. Rubric 观察(写入 rubric-memo.md
打分维度 / 公式 / bucket 边界相关的观察:
```markdown
### 需要写进 rubric-memo.md 的新观察
1. **ER 在情感向场景的真实权重应 ≥ ×2.0**:与谁问你了 6x 流量比是 v2 rubric 最强的反事实证据
2. **议题分享冲动 (TS) 是隐藏维度**joker / "她不一样" / 滤镜重构提供了安全的自嘲身份,转发不暴露处境,TS=5 的样本
3. ……
```
每条观察必须可追溯到具体数据点(不写"情感很重要"——写"ER5/SR2 vs ER3/SR4 同 composite 下流量差 6x")。
#### 4b. 写作 Pattern 观察(写入 script_patterns.md
Diff `scripts/<id>.md`pre-shoot 草稿,可能是 cheat-seed 写或用户写)vs `videos/<id>/script.md`(实际拍摄稿——cheat-shoot 时用户提供的版本),找出**改动且对流量有明显影响**的部分:
| 用户做了什么 | 流量影响 | 是否提议追加 pattern |
|---|---|---|
| 砍掉某段 | 实绩 ≥ 中枢 → "砍掉没伤流量"——验证那段冗余 | 是,加到 script_patterns.md "用户改稿历史观察"表 |
| 加了某句 / 互动钩子 | 实绩超中枢 → 可能是新 pattern | 是,候选 Pattern N,标 ≥1 样本待验证 |
| 改了风格(如开头软化) | 高于同类样本 → 风格改动有效 | 是,候选 Pattern N |
| 没动结构 / 改动与流量无关 | — | 不追加 |
输出格式:
```markdown
### 需要写进 script_patterns.md 的新 pattern 候选
1. **用户改稿模式**: 砍掉 [X 段] / 加了 [Y]
- 流量影响:实绩 [N] vs 中枢 [M],[偏差 / 命中]
- 建议:追加到 script_patterns.md 的"用户改稿历史观察"表
2. **新 pattern 候选 N**[一句话描述]
- 单样本支持
- 触发条件:[何时该用]
- 建议:追加到 script_patterns.md 末尾的"新发现的 Pattern"段,标 ≥1 样本待验证
```
询问用户:"要把这些追加到 script_patterns.md 吗?(yes / no / 选择哪几条)"。**用户确认后才追加**——避免把单点观察直接写成正式 pattern。
> **rubric 进化 ≠ 写作进化**——两者解耦:
> - rubric_notes.md 学的是"哪些维度真的预测流量"
> - script_patterns.md 学的是"什么写法真的能起作用"
> 可能有交叉(如 MS 维度与"互动钩子" pattern),但记录在两个文件里是因为**作用域不同**——rubric 改了影响所有未来打分,pattern 改了影响所有未来 draft。
如果 `videos/<id>/script.md` **缺失**cheat-shoot 时用户标 `script_lost` → 跳过 4b,没法 diff。
如果 `script_consistency = "consistent"`(用户拍时没改稿)→ 4b 仍然有意义(diff 也许是空),但可以快速跳过细查。
如果 `script_consistency = "modified"`(用户拍时改了)→ **4b 是核心**,重点学这次改动 → 流量影响。
### Phase 5: 落盘到 ## 复盘 段
用 Edit 工具,**仅追加**到现有 `## 复盘` 段(如有占位 `(待填)` 行先删除):
```markdown
## 复盘
**复盘时间**: 2026-05-07(发布 T+3d
**抓取时间**: 2026-05-07 09:30
**数据来源**: manual paste
### 实绩数据
[Phase 2 内容]
### Top 评论关键词
[Phase 2 内容]
### 哪些预测被验证 / 推翻
[Phase 3 内容]
### 需要写进 rubric-memo.md 的新观察
[Phase 4 内容]
```
**写完后再次校验**:读取保存后的文件,对比**所有** `## 预测...` 段(v1 / v2 / legacy)的合并哈希应等于 Phase 0 cache 的合并哈希。**任一段被改 → 报错并回滚**。
### Phase 6: 写入 rubric-memo.md + script_patterns.md
#### 6a. rubric-memo.mdPhase 4a 的输出)
按 [observation-lifecycle.md](../../shared-references/observation-lifecycle.md) 的 blind leak guard,追加到 `rubric-memo.md``## 观察记录` 段。这里可以包含真实样本名、实绩数据、评论关键词和链接;这些内容**绝不**写入 `rubric_notes.md`
```markdown
### YYYY-MM-DD [标题简称] (id) — [一句话定性]
- 预测:composite=X.XXbucket=Y
- 实绩:播放 / 点赞 / 评论 / 转发(带 T+Nd 标注)
- Top 评论关键词:[简短摘录 + 赞数]
- 判断:哪个维度被验证/推翻?为什么?
- Rubric 调整:[如果有,写明 "下次打 XX 类文章时改 YY"]
- 详见:[predictions/<file>.md]
```
**检测跨样本 pattern**:扫描 `rubric-memo.md` 已有"观察记录",看新观察是否与某条已有观察形成 ≥2 样本支持。命中则在 `rubric-memo.md` 升级到"重大跨样本观察"段。只有在后续 `/cheat-bump` 落地时,才把已验证的规律抽象成通用语言写入 `rubric_notes.md`
#### 6b. script_patterns.mdPhase 4b 的输出,**用户确认后才写**)
如 Phase 4b 用户回 "yes" 或选择性确认了某几条:
- "用户改稿模式" → 追加到 script_patterns.md 的"用户改稿历史观察"表
- "新 pattern 候选 N" → 追加到末尾"新发现的 Pattern"段,**显式标 ≥1 样本待验证**
新 pattern 候选的格式(同 [script_patterns.template.md](../../templates/script_patterns.template.md) 的 Pattern 11/12 示例):
```markdown
### Pattern N(来自 [视频简称],单样本待验证)
**现象**[Phase 4b 描述]
**原理**:[为什么有效——基于这一次观察的猜测]
**触发条件**[何时该用]
**待验证**:需要 ≥2 样本支持才能升正式 pattern。
```
跨样本 pattern 升正式:扫描"新发现的 Pattern"段,看是否有 ≥2 样本支持同一现象 → 升到核心 pattern 库 + 删 "待验证" 标记。
如用户在 Phase 4b 全否("no")→ 跳过 6brubric-memo.md 仍照写。
### Phase 7: 检测 bump 触发
`.cheat-state.json``consecutive_directional_errors` 字段,按本次复盘判定向更新:
- 本次预测高估(实绩 < 中枢 -25% → push `["high"]` + 记录 deviation_magnitude(如 0.5x / 0.3x
- 本次预测低估(实绩 > 中枢 +25% → push `["low"]` + 记录 deviation_magnitude
- 在 ±25% 内 → 不 push
**Claude 判断是否提议 bump**(不是固定门槛):
```
判断维度:
1. 连续同向次数(参考默认:≥3
2. 单次偏差幅度(参考默认:>2x 或 <0.5x 算极端)
3. 偏差是否能解释为单一维度漏判(如 ER 或 SR 一致偏离)
4. 用户是否在复盘里反复提到同一现象
任一足够强 → 提议 bump
- 3 次连续同向,每次都中等偏差 → 提议
- 1 次极端偏差(如 ≥10x),即使没连续 → 提议("一次性强信号"
- 2 次同向 + 评论区出现一致的反向证据 → 提议("评论 + 数据双信号"
不提议的情况:
- 3 次同向但每次都很小(<25%)→ 可能只是噪声
- 偏差跨多个维度无清晰方向 → bump 不知道改什么
```
提议时输出:
```
🚨 检测到 [系统性偏差信号] / [极端单点偏差] 。
[简短描述:连续 N 次 / 1 次极端 / 评论双信号 等]
这可能是 rubric 系统性偏差的信号。建议:
- 跑 /cheat-bump 看是否需要升级公式
- 或先看 /cheat-status 详细分析
注:本次提议是 [default-aligned: 满足 ≥3 同向] / [judgment-driven: 1 次 10x 强偏差]
```
更新 state file
```json
{
"calibration_samples": <+1>,
"pending_retros": [<>],
"last_retro_at": "<ISO>",
"consecutive_directional_errors": [...]
}
```
## Key Rules
1. **预测段 immutable**。Phase 0 cache + Phase 5 校验是双保险。任何 hash 不一致 → 报错回滚
2. **数据来源必须标注**`数据来源: manual paste``数据来源: adapter:douyin-session` 写进复盘段
3. **观察可追溯**。每条新观察引用具体数据点
4. **不在复盘里 bump**。Phase 7 只**提议** bump,实际升级走 `/cheat-bump`——避免一次操作做两件事
5. **早复盘标记**。RETRO_WINDOW_DAYS 不到就复盘 → state file 记 `early_retro: true`bump 时这种样本权重降级
## Refusals
- 「这条数据已经看过了,但你假装没看,按预测时的盲度做复盘」 → 复盘本来就是看完数据再做的;这个表述本身没有违规,但要确认用户没在 prediction 写之前透露过数据
- 「把预测段的概率分布改一下,让复盘看起来更准」 → 拒绝。原则 #1
- 「跳过观察提炼,直接结束」 → 拒绝。新观察是 rubric 进化的唯一燃料;缺它复盘退化为"看一眼"
- 「直接 bump,不要单独走 /cheat-bump」 → 拒绝。bump 流程有完整的跨模型审 + cleanup passretro 是触发器不是执行器
## Integration
- 前置:`/cheat-publish` 已登记 + 时间窗口达到
- 下游:累计 `consecutive_directional_errors` 满 3 → 触发 `/cheat-bump` 提议
- 状态字段更新:`calibration_samples` +1(这是 cheat-status 显示进度的关键)
- pending_retros:剔除本条
- 与 [observation-lifecycle.md](../../shared-references/observation-lifecycle.md) 紧耦合:每次复盘是观察新增的入口
+213
View File
@@ -0,0 +1,213 @@
---
name: cheat-score-blind
description: |
INTERNAL sub-agent for blind 7-dim rubric scoring. **NOT a user-facing skill — do NOT invoke from main conversation.** Called via Task tool by cheat-score / cheat-predict / cheat-bump to get a context-isolated score on a script. Receives ONLY script_path + rubric_notes_path; refuses any other input. Outputs strict JSON: 9 dimensions × {score 0-5, confidence enum, one-line reason}. **Hard refuses to Read** .cheat-state.json, predictions/*, retro 段, or anything that could leak post-publish data. This is channel B in the 3-channel calibration model (A=main, B=blind sub, C=cross-model).
allowed-tools: Read, Glob, Grep
argument-hint: <script-path> <rubric-notes-path>
---
# /cheat-score-blind — Channel B (blind scorer sub-agent)
> ⚠️ **这是子 agent,不是用户 skill**。只能由 `cheat-score` / `cheat-predict` / `cheat-bump` 通过 Task tool spawn。用户直接 trigger 没有意义——主对话已经被污染,调用 blind sub-agent 在主 context 里跑不构成隔离。
---
## Why this exists(绝不可省的背景)
cheat-on-content 的 7/9 维打分原本 inline 在主对话——但主 Claude 已经看过:
- 用户对话历史(含偶然提到的播放数 / 评论 / 情绪)
- 已发布作品的实绩数据
- 历史 `predictions/*.md` 含复盘段(**严重污染**
- 用户的赞美 / 抱怨 / 期待
inline 打分 = **被污染的"盲"预测**。问题在 `cheat-bump` Phase 2 校准池重打时最严重:Claude 知道每条实绩才回追 TN/CC 分,rank 一致性可能 overfit 不是真信号。
**channel B 的角色**:用 Task tool 把打分动作丢进一个**全新 context**——这个 sub-agent 没看过主对话、没读过 state、没碰过 predictions/。它只看 script 全文 + rubric_notes.md,按 rubric 打分。
输出回传主对话后,主 Claude 自己对比、做最终决策。隔离的是**打分这个动作的输入**,不是决策权。
## 三 channel 模型
| Channel | 输入 | 用途 | 风险 |
|---|---|---|---|
| **A** = 主对话 | 全部上下文 | 跟用户交互、写 retro、决策 | 被实绩 / 用户态度污染 |
| **B** = blind sub-agent (this) | **只** script + rubric_notes.md | 给一份未受污染的打分作为 anchor | 仍是 ClaudeRLHF prior 共享 |
| **C** = 跨模型 audit (`mcp__llm-chat__chat` to qwen-max) | 校准池数据 + 新公式 | bump 终局 sanity check | RPM 限制、模型差异、单点 |
A 决策时把 B 当对照看 disagreement**不当真理**。C 只在 bump 终局调一次。
---
## Inputs**唯一被允许的输入**
| 必填 | 来源 | 说明 |
|---|---|---|
| `<script-path>` | 主 Claude 通过 Task prompt 显式传入 | `scripts/<id>.md` 全文 |
| `<rubric-notes-path>` | 同上 | 用户项目根 `rubric_notes.md` 当前 rubric 公式 + 维度定义 |
**仅此两个文件可读**。其他一切**硬拒绝**——见下方 "Hard refusals" 段。
## 禁止读取(hard list
下面这些路径 / 模式 sub-agent **绝不能 Read** —— 即使主 Claude 在 Task prompt 里手滑塞进来,也要拒绝并在 JSON 输出标对应 `refusal` 码:
| 路径模式 | 为什么禁 | refusal_code |
|---|---|---|
| `.cheat-state.json` | 含 calibration_samples / pending_retros / last_published_at / shoots — 全是后视数据 | `blocked_contaminated_input` |
| `predictions/*.md` | 含 `## 预测` 段 + `## 复盘` 段,复盘段就是实绩 | `blocked_contaminated_input` |
| `videos/*/report.md` | T+3d 抓回的真实数据 | `blocked_contaminated_input` |
| `videos/*/script.md` | 后改拍摄稿,复盘时被对照 | `blocked_contaminated_input` |
| `STATUS.md` | cheat-status 渲染的看板,含过去数据 | `blocked_contaminated_input` |
| `.cheat-cache/usage.jsonl` | 行为 log | `blocked_contaminated_input` |
| **`rubric-memo.md`** | **cheat-bump 升级 Memo 累积档案——含真实视频名 + 实绩 + 派生证据。这是 channel B 的最大泄漏入口(PR #11 实测复现)** | **`blocked_rubric_memo`** |
| **`audience.md`** | **cheat-persona 从复盘评论派生的受众画像——含评论证据 / 实绩信号。属 channel A creative 资产,进 blind 打分 = 实绩泄漏** | **`blocked_audience`** |
| 任何含"播放 / 阅读 / 点赞 / 评论数 / 转发 / w / 万 / k / M"的文件 | 直接污染 | `blocked_contaminated_input` |
**白名单只有两个**
- `scripts/<id>.md`pre-shoot 草稿,传入参数)
- `rubric_notes.md`(评分公式 + 维度定义,**应**只含通用语言;如发现实绩数字 → 标 `non_blind_warning` 并降 confidence
如果主 Claude Task prompt 漏传了某条路径,sub-agent 主动询问"我只允许读 script + rubric_notes,缺哪个?"——**绝不**自己去 Glob 探测项目结构补全。
> ⚠️ **白名单兜底自检**:读完 `rubric_notes.md` 后必跑 `grep -E '\\d+\\s*[wWmMkK万]|播放|实绩|实际'`——命中 → 标 `self_check.any_contamination_signal: true` + `refusal: "non_blind_warning"`,所有维度 confidence 降 medium 并把违禁 snippet 摘抄进 contamination_note 字段。**仍输出 dimensions** 让主 Claude 知道发生了什么——拒绝输出比误判更糟,但要诚实标注。
---
## Workflow
### Phase 0:边界自检
1. 解析 Task prompt 拿 `<script-path>``<rubric-notes-path>`
2. 校验路径符合白名单——不在 `scripts/` 下的 .md → 拒绝(除非主 Claude 显式说明"这是临时草稿临时路径,标 `non_standard_path: true`"
3. Read `<rubric-notes-path>` → 解析当前 rubric_version + 维度数量(7 或 9+ 公式
4. Read `<script-path>` → 拿到 script 全文 + 字数
⚠️ **不要做的事**
- 不要为了"看看用户做啥账号"去 Read `benchmark.md` —— benchmark 是 Channel A 的 context,不属于本 sub-agent
- 不要为了"看看历史风格"去 Glob `predictions/` —— 那是污染源
- 不要去 Read `.cheat-state.json` 看 calibration 进度 —— 你**完全不需要知道**主 Claude 跑了多少篇
### Phase 1:按 rubric 打 N 维分
`rubric_notes.md` 当前 rubric 公式:
- v07 维等权(ER / SR / HP / QL / NA / AB / SAT)—— 默认起步
- v1:用户校准过的(权重不同)
- v2 / v2.1 / ...:含 MS / TS 等新增维度(9 维)
对每个维度:
1. 给一个 **0-5 整数分**
2. 给一个 **per-dim confidence** enum`high | medium | low`
- high:稿子里有直接证据(一句话指向该维度)
- medium:可推断但需要解释
- low:稿子信号太弱,纯估
3. 给一行 **理由** ≤ 30 字,**必须引用稿子里具体词或场景**
不算 composite——composite 是公式行为,主 Claude 用回传的维度分自己算。
### Phase 2:返回严格 JSON
输出**只能**是一个有效 JSON。所有 markdown 解释都封禁——主 Claude 要的是结构化数据回主 context 解析。
```json
{
"subagent_version": "v1",
"rubric_version": "v2",
"script_path": "scripts/2026-05-04_abc123_短title.md",
"script_hash": "<sha256:12 of script content>",
"scored_at": "<ISO 8601 +08:00>",
"dimensions": {
"ER": { "score": 4, "confidence": "high", "reason": "PPT加油猫猫开头—具象画面,情绪反差强" },
"SR": { "score": 3, "confidence": "medium", "reason": "AI焦虑是议题但非热点对峙" },
"HP": { "score": 5, "confidence": "high", "reason": "首句\"第七页大屏中央 加油猫猫\"具象反差" },
"QL": { "score": 5, "confidence": "high", "reason": "\"加油猫猫救了我一命\"双关金句" },
"NA": { "score": 4, "confidence": "medium", "reason": "单线反思+收束,清晰但不复杂" },
"AB": { "score": 4, "confidence": "medium", "reason": "一人公司题但AI焦虑普适" },
"SAT": { "score": 2, "confidence": "high", "reason": "共情调,几乎无讽刺" }
},
"input_status": {
"rubric_notes_read": true,
"script_read": true,
"any_other_file_read": false
},
"self_check": {
"saw_play_numbers": false,
"saw_comments": false,
"saw_retro_segment": false,
"any_contamination_signal": false
},
"refusal": null
}
```
`refusal != null` 的合法值:
- `"blocked_contaminated_input"`Task prompt 传了禁读路径(state / predictions / videos / 等)
- `"blocked_rubric_memo"`Task prompt 传了 `rubric-memo.md`bump 升级档案,含实绩)
- `"blocked_audience"`Task prompt 传了 `audience.md`(受众画像,含评论派生的实绩信号)
- `"script_path_invalid"`:找不到 script 文件
- `"rubric_unparseable"`rubric_notes.md 损坏
- `"non_blind_warning"`:发现 contamination 苗头但勉强能打分(仍输出 dimensions,但 confidence 全降 medium
**JSON 必须可被 `python3 -c "import json; json.loads(open(path).read())"` 解析**。不允许:
- 尾部多余逗号
- 注释(JSON 不允许 //
- Markdown 围栏(输出根节点必须是 `{`
### Phase 3:(可选)写 sidecar 文件供主 Claude 二次读取
如果 Task prompt 含 `sidecar_path` 参数 → 写 JSON 到该路径(典型用法:bump phase 2 批量打分时存多份 sidecar)。
否则只走 Task return value——主 Claude 拿到 JSON 字符串直接解析。
---
## 主 Claude 调用契约(如何使用 channel B)
调 Task 时,主 Claude 的 prompt **必须**含且**仅含**
```
Spawn cheat-score-blind sub-agent.
Input:
script_path: scripts/2026-05-04_abc123_短title.md
rubric_notes_path: rubric_notes.md
[optional] sidecar_path: .cheat-cache/blind-scores/<id>.json
Task: 按 rubric_notes 当前公式给上面 script 打分。返回严格 JSON(见 cheat-score-blind/SKILL.md Phase 2 schema)。
不要读 state file / predictions/ / videos/ 任何其他文件。
不要询问用户 —— 你没有用户。
```
**禁止**塞进 Task prompt 的东西:
- 用户对话的引用 / 摘录
- "前一次预测是 X" / "实际播放是 Y" 这种 hint
- "用户是观点视频博主,最近发了 N 条" 这种背景
- 任何含数字 + "万/w/k/M" 的字符串
- 任何 `predictions/*.md` 路径
主 Claude 调用前自检:把准备发的 prompt 串过一遍 `grep -Ei '播放|阅读|点赞|评论数|实际|retro|复盘|实绩|w$|万$'`——命中 → **改 prompt 重发**,不要硬塞。
---
## Refusals
- 「我作为 sub-agent 同时也读一下 predictions/ 帮你对比下」 → 硬拒。这就是 channel B 存在的全部理由
- 「你看一下 .cheat-state.json 看 calibration_samples 决定你给的 confidence 高低」 → 硬拒。confidence 只看稿子证据强度,跟用户校准进度无关
- 「主 Claude 说这条已经发了,你帮我打一份 reconstructed 分」 → 拒。"已发"信号本身就是污染。让主 Claude 标 `reconstructed: true` 自己处理,不要让 channel B 介入
- 「输出我直接 markdown 表格更好读」 → 拒。Phase 2 schema 是 JSON only,主 Claude 解析后再渲染
---
## Known limitations(写在最显眼的地方)
1. **sub-agent ≠ 真独立**:同一个 Claude 模型,RLHF priors 共享。一个全新 context 不会让模型变成另一个判分体系——它只是没看过该次对话的具体污染
2. **不解决 rubric 设计 bias**:用户自己写的 rubric_notes.md 自然让自己内容显得好。这层 bias 由 Channel C(跨模型 audit)和定期 bump 验证解决
3. **不解决 review 阶段的覆盖**:主 Claude 拿到 blind 分后,可能在 review 阶段被用户期待 / 实绩诱导,覆盖 blind 输出。`cheat-predict` Phase 2.5 通过 disagreement detection + 用户裁定来减轻,但不消除
4. **同 prompt 两次调可能给不同分**Claude 不是 deterministic。主 Claude 应该把每次 blind score 当一次采样,不当唯一真理——但要记录而不是丢弃差异
## Integration
- **`cheat-score`** Phase 2:默认 delegate 到本 sub-agent(替代旧的 inline 打分)
- **`cheat-predict`** Phase 2:默认 delegatePhase 2.5 用 disagreement detection
- **`cheat-bump`** Phase 2**强制** delegatebump 时**不接受 self-scored fallback**
- **`cheat-retro`**:不调用——retro 本来就看实绩,blind 无意义
+159
View File
@@ -0,0 +1,159 @@
---
name: cheat-score
description: 给单篇稿子打 rubric 分。**只在控制台输出,不写文件,不预测**。触发词:"打分这篇 [path]"/"score this [path]"/"给这稿子打分"/"先打分看看"。是 cheat-predict 之前的轻量探索动作。
argument-hint: <draft-path>
allowed-tools: Read, Glob, Grep
---
# /cheat-score — 单稿打分
打分但**不预测**。用户用它快速看稿子的 composite,决定是否值得进入正式预测流程。
## Overview
```
[用户:打分这篇 draft.md]
[读 draft.md + rubric_notes.md]
[逐维度打 0-5 + 写一行理由 + 算 composite]
[控制台输出:评分 + composite + 推荐下一步]
[结束 — 不写任何文件]
```
## Constants
- **RUBRIC_PATH = rubric_notes.md** — 当前 rubric 来源
- **OUTPUT_DETAIL = full** — full: 含每维度理由;compact: 仅分数表
> 💡 调用时覆盖:`/cheat-score draft.md — OUTPUT_DETAIL: compact`
## Inputs
| 必填 | 来源 |
|---|---|
| `<draft-path>` | 用户作为参数传入;如缺失则在对话里询问 |
| `rubric_notes.md` | 用户项目根 |
| `.cheat-state.json` | 用户项目根(用于读当前 `rubric_version` 与 mode |
## Workflow
### Step 1:前置检查
1.`.cheat-state.json` → 不存在则提示用户先跑 `/cheat-init`,停止
2.`<draft-path>` → 不存在或无内容 → 报错并停止
3.`rubric_notes.md` 找到当前生效的公式段(一般在"当前评分维度"或"综合分公式"位置)
### Step 2:识别公式与维度
`rubric_notes.md` 解析出:
- 当前 rubric_version
- 维度列表与权重(如 `ER×1.5 + SR×1.5 + HP×1.5 + QL + NA + AB + SAT`
- 归一化常数(如 `/ 8.5 × 2.0`
- 每个维度的 0-5 含义(从"当前评分维度"段表格读)
如果 `rubric_notes.md` 格式与预期不符(用户手改过结构)→ 询问用户当前公式是哪一行,**不要自己猜**。
### Step 3**delegate 到 blind sub-agent**(不再 inline 打分)
主对话已经被用户对话 / 已发数据 / 历史 retro 段污染——inline 打分等于带着后视镜判分。
改成**通过 Task tool 调 `/cheat-score-blind` sub-agent**,主 Claude 只做调度 + review。详见 [skills/cheat-score-blind/SKILL.md](../cheat-score-blind/SKILL.md)。
**Task prompt 模板****只能含**下面这些):
```
Spawn cheat-score-blind sub-agent.
Input:
script_path: <用户给的 draft path>
rubric_notes_path: rubric_notes.md
Task: 按 rubric_notes 当前公式给上面 script 打分。返回严格 JSON(见 cheat-score-blind SKILL.md Phase 2 schema)。
不要读 state file / predictions/ / videos/ 任何其他文件。
不要询问用户 —— 你没有用户。
```
**禁止**塞进 Task prompt 的东西([cheat-score-blind/SKILL.md](../cheat-score-blind/SKILL.md) 的"主 Claude 调用契约"段):
- 用户对话引用 / 摘录
- 含播放数 / 万 / w / k 等字眼
- "前一次预测是 X" / "实际播放是 Y" 等 hint
- 任何 `predictions/*.md` 路径
调用前 grep 自检:`echo "<prompt>" | grep -Ei '播放|阅读|点赞|评论数|实际|retro|复盘|实绩|w$|万$'` 命中 → 改 prompt 重发。
### Step 4:解析 sub-agent 回传 JSON + review
sub-agent 返回严格 JSON。主 Claude
1. 解析 dimensions 段(含 score + per-dim confidence + reason
2. 校验 `self_check.any_contamination_signal == false`,否则警告
3. 按 rubric_notes 公式算 composite(公式逻辑在主,分数来自 sub-agent)
4. **不修改 sub-agent 给的维度分**——score 只是显示。如果用户挑刺("AB 给 3 不是 4"),主 Claude 记录到 `User Override` 但 sub-agent 原始分留档
如果 sub-agent 返回 `refusal != null`
- `blocked_contaminated_input` → 报告 Task prompt 含违禁字段,让主 Claude 重发
- `script_path_invalid` → 检查路径
- `rubric_unparseable` → 提示用户 rubric_notes.md 损坏
- `non_blind_warning` → 仍接受 dimensions(但 confidence 全 medium),警告
### Step 5:算 composite + 输出
按当前公式算综合分。控制台输出(OUTPUT_DETAIL=full):
```
📊 [draft.md 短标题] — 打分(rubric: v2
| 维度 | 分 | 理由 |
|---|---|---|
| ER (情感共鸣) | 5 | "半夜三点翻聊天记录" 极端具象 |
| HP (钩子强度) | 5 | IS 句一句锁定受众 |
| QL (金句密度) | 5 | MVP 句"间歇性希望"独立可传 |
| NA (叙事性) | 3 | 平铺直叙,弱弧线 |
| AB (受众广度) | 5 | 暗恋/前任普适 |
| SR (社会议题共振) | 2 | 纯个人情感,无社会托底 |
| SAT (讽刺深度) | 4 | 致谢段自指反讽 |
公式:(ER×1.5 + SR×1.5 + HP×1.5 + QL + NA + AB + SAT) / 8.5 × 2.0
composite = (5×1.5 + 2×1.5 + 5×1.5 + 5 + 3 + 5 + 4) / 8.5 × 2.0 = **8.24**
📍 落在 30-100w 桶(基于 starter-rubrics 的 bucket 边界)
下一步建议:
- 如果你已写定最终稿、准备发布 → 说 "启动预测"
- 如果想再改稿子 → 改完再打一次(多次打分不留痕迹)
- 如果想看历史相近 composite 的样本 → 说 "找 composite 8.0-8.5 的锚点"
```
OUTPUT_DETAIL=compact 时仅输出分数表 + composite,不附理由列。
### Step 6**绝不**做的事
- ❌ 写任何文件(包括 predictions/、rubric_notes.md、candidates.md
- ❌ 给 bucket 概率分布(那是 cheat-predict 的活)
- ❌ 触发"已发布"或"复盘"逻辑
- ❌ 提议 rubric 升级(即使打分时发现明显异常也只在控制台提示,不动 rubric)
## Key Rules
1. **打分走 sub-agent**。主 Claude 不再 inline 打分。看 [cheat-score-blind/SKILL.md](../cheat-score-blind/SKILL.md) 的隔离协议
2. **整数分**。不允许 4.5、3.7
3. **盲打优先**。sub-agent 只看 script + rubric,天然盲——这是它存在的全部理由
4. **理由是诊断工具**。每个维度的 1-30 字理由不是装饰——复盘时用来找出哪个维度判断错了
5. **不写文件**。这是 score 与 predict 的核心区别。score 是探索,predict 是承诺
6. **不算 candidate composite**。candidates.md 里的 composite 字段在 cheat-trends/cheat-recommend 里写——score 只服务"已写好的具体稿子"
## Refusals
- 「打分顺便预测一下」 → 拒绝。请改用 `/cheat-predict`。原因:predict 必须走 blind check + 写 immutable 日志,score 跳过这些
- 「打完分把分数写进 rubric_notes.md 的观察段」 → 拒绝。observation lifecycle 规定观察必须有"实绩 vs 预测"对比,光有打分不构成观察
- 「能不能直接告诉我会不会爆」 → 拒绝。给具体 composite + bucket 的判定要求走 predict 流程;score 只输出当前 rubric 下的机械计算
- 「跳过 blind sub-agent 让主 Claude 直接打」 → cheat-score **不接受**这种 escape hatch(与 cheat-predict 不同;cheat-predict 有 `--skip-blind`)。score 是轻量探索,没理由放弃隔离。如真的 Task tool 不可用 → 提示用户配置后再试
## Integration
-`cheat-predict` 的前置探索:用户可以反复 score 不同稿子版本,确定一份再 predict
- score 不更新 `.cheat-state.json`——这是无副作用操作
- 如果用户连续 score 同一稿子 ≥3 次 → 控制台温和提示"反复打分会引入决策疲劳,差不多可以决定了"
+432
View File
@@ -0,0 +1,432 @@
---
name: cheat-seed
description: 跟用户对话讨论选题——**默认一次一个**,用户主动给主题或经历,AI 围绕用户的输入深挖、提炼角度、写一份 draft。不是 AI 拿三个开放问题追用户,也不是一次给 5 个候选。触发词:"找选题"/"我想做一条 X"/"最近有个想法"/"seed"/"启动种子"。可选 batch 模式:`/cheat-seed --batch 5` 走旧的 brainstorm 5 候选 + 写 5 draft 流程。
argument-hint: [— batch: N] [— sources: <comma-separated>]
allowed-tools: Bash(*), Read, Write, Edit, Glob, WebFetch, Skill
---
# /cheat-seed — 选题对话(默认)/ 批量 brainstorm(可选)
cheat-seed 的核心是**跟用户讨论选题**,不是机械地 brainstorm。好内容来自用户的真实经历 + 观察 + 情绪——这些是 AI 不可能凭空 brainstorm 出来的。AI 的角色是**听用户讲 → 帮提炼角度 → 写一份 draft**,不是 dump 15 候选让用户挑。
**默认模式**:对话式一次一个。
**Batch 模式**`--batch N`):保留旧的 brainstorm N 候选 + 写 N 份 draft 流程,给"完全没想法 + 想批量初始化"的用户。
## 三种 Mode(自动识别)
```
Mode A — 用户主动给主题(**最常见**):
用户:"/cheat-seed" + 直接说"我想做一条关于 X 的"
或:"/cheat-seed 我最近开会被领导..."
AI 围绕 X / 这件事**深挖**——什么瞬间触发?最让你 [情绪 / 不爽 / 觉得有意思] 的是哪点?
收敛到一个具体角度 → 提议 → 用户认可 → 写 1 份 draft → 完成
问"下一篇?" 或用户说"今天就这样"
Mode B — 用户给方向但不具体:
用户:"最近想做点关于 [职场 / 婚恋 / AI / ...] 的"
AI:"[范围] 太广。最近你接触到的具体哪件事让你想做这个方向?"
收敛到 Mode A 的具体经历
Mode C — 用户完全没想法(少见):
用户:"我不知道做什么" / "帮我想个题"
AI"好,进 brainstorm 模式——先抓热点 + 你之前的兴趣方向,给你 1 个建议"
跑 trend-sources 抓热点 + 读 candidates.md / predictions/ 看用户历史
提议 1 个角度(不是 5 个) → 用户认可 → 写 draft
Batch Mode — 用户显式要批量(`/cheat-seed --batch 5`):
按旧版 brainstorm 流程:3 问题 → 15 候选 → 用户挑 → 写 5 draft。
给"今天想一次性把未来 2 周的选题搞定"的用户。
```
**关键纠正**(与旧版的区别):
- AI **不主动开放问**——等用户给输入再深挖
- 一次一个选题,不是 5 个
- 默认对话式 + 一次一个,batch 是 escape hatch
## Constants
- **DEFAULT_TREND_SOURCES = ["manual-paste"]** — 仅 Mode C / Mode A 灰色场景 / Batch 用到。用户可在 state 里加 aihot / trendradar-mcp
- **TREND_TOOL_ROUTING** — 按 `content_form` 路由数据源,详见 [shared-references/data-source-routing.md](../../shared-references/data-source-routing.md)
- **MODE_B_MAX_REPROBE_TURNS = 2** — Mode B "为什么" 反问最多 2 轮;超过则转 Mode C
- **MAX_DEEP_DIVE_TURNS = 4** — Mode A 收敛阶段最多 4 轮反问,避免 AI 过度盘问
- **WITH_DRAFT = yes** — 默认确认角度后立刻写 draft;用户可说 "等下,我自己写" 跳过
- **DRAFT_LENGTH** — 派生自 `state.typical_duration_seconds`30s→100-200字 / 90s→250-500字 / 240s→600-1000字 / 450s→1100-2000字 / 900s→2200+字
- **HUMANIZE_DRAFT = on**(默认)/ off —— 写完 draft 后用 `humanizer` skill 过一遍,去掉 AI 写作 tellsem-dash 滥用 / rule of three / inflated 词汇 / 空泛归因等)。off 时直接出原始 AI draft。**只 humanize 正文,不动 header 的"必须改写"警告**
## Inputs
| 必填 | 来源 |
|---|---|
| `.cheat-state.json` | 读 calibration_samples / typical_duration / cadence |
| `rubric_notes.md` | 读当前 rubric(粗打分用) |
| `script_patterns.md` | 读已有 pattern(写 draft 时按 cheat sheet 选结构) |
| `predictions/*.md`(如有) | 已发历史,brainstorm 时作为 context |
| `audience.md`(如有) | 受众画像——选题 / 写稿时的"谁在看"镜子(由 `/cheat-persona` 派生) |
## Workflow
### Phase 0: 前置检查 + 加载所有 context**核心:3 个 context 来源**
1.`.cheat-state.json` → 不存在则提示先跑 `/cheat-init`
2.`rubric_notes.md` 拿当前公式(粗打分用)
3.`script_patterns.md`——写 draft 时按 cheat sheet 选结构
4. **读已有 prediction 文件**(含 init 时 import 的 reconstructed)作为 **context 来源 A**(用户自己历史):
- 0 个 → A 为空
- ≥1 个 → A 有内容,提取 (title / 7 维 / 实绩)
5. **读 `benchmark.md`**(如存在)作为 **context 来源 B**(对标账号):
- `state.benchmark_status = imported` → B 有内容,提取对标账号的样本主题分布、调性、Pattern
- `state.benchmark_status = none / pending` → B 为空
6. **读 `audience.md`**(如存在且非空骨架)作为 **受众镜子**
- Confidence 🟡 以上(有真实复盘数据派生)→ 选题 / 写 draft 时作为"这个 persona 会在乎吗"的检验视角
- Confidence 🔴/🟠(空 / 仅 benchmark seed)→ 当弱参考,不当硬约束
- **不进粗打分**——audience.md 是 creative lens,粗打分仍只用 rubric。persona 影响"写什么 / 哪个角度",不影响分数
7. 检查用户的入参——是否含具体 topic / 经历,决定走 Mode A/B/C/Batch
**brainstorm 时的 context 优先级****Claude 判断**——下面是参考默认):
- **A 主导**(用户自己数据):当 Claude 判断用户数据已能驱动方向时(参考默认:`calibration_samples ≥ 10`,但 Claude 可以更早——如 N=5 但出现 ≥3 个与 benchmark 明显不一致的强样本)
- **B 主导**benchmark):用户数据少 + benchmark 有内容时
- **B 缺席**benchmark 为空)+ 用户数据少:纯靠用户 input + 抓热点;明确告诉用户"没 benchmark 也没足够自己数据,建议跑 /cheat-learn-from 后再回来 brainstorm"
判断依据**不是死磕样本数**,而是看:
- 用户最近 N 个样本的实绩**是否与 benchmark 的高表现样本类型一致**——一致说明 benchmark 仍有借鉴价值;不一致说明用户已经走自己的路
- 用户的样本**多样性**——3 篇都是同类内容不算成熟;3 篇覆盖不同类目反而比 10 篇同类更可信
### Phase 1: Mode 分流
读用户输入,识别:
**含具体名词 + 情绪 / 经历词**"我昨天开会..." / "我看到 X 让我..." / "我对 Y 觉得...")→ **Mode A**(深挖;如话题是时事,Phase 2A.5 询问要不要拉外部数据)
**含方向词但无具体内容**"想做职场" / "AI 方向" / "婚恋")→ **Mode B**(单问"为什么想做这个"——用户内省窗口,**不调任何热点工具**)
**显式说没想法**"不知道做什么" / "帮我想" / "随便给个")→ **Mode C**(按 [data-source-routing.md](../../shared-references/data-source-routing.md) 调热点工具 + 用户挑 + 回到内省;都不行则走"聊经历"三选项兜底)
**显式 `--batch N`**(用户主动批量)→ **Batch Mode**
**纯 `/cheat-seed` 无附加内容****询问入口问题**
```
你今天想干嘛?
- 有想做的主题 / 经历 → 直接告诉我("我想做一条 X" / "我最近 X..."
- 知道大致方向 → 告诉我("想做职场" / "AI 方向")→ 我会单问你为啥想做
- 完全没想法 → 说"帮我想"→ 我用 [aihot / trendradar] 拉今天的热点给你看
- 批量搞定 → 说 "batch <N>"
(我不会拿一堆开放问题追你——你给一句话我就开始)
```
注意这是**唯一的开放式问题**——只在用户**纯触发** `/cheat-seed` 时问。如果用户已经在触发词里给了内容("/cheat-seed 我想做..." 或 "找选题 我最近开会..."),直接进 Mode A/B/C 不再问这一句。
### Phase 2A: Mode A 深挖(用户给了具体 topic / 经历)
**核心原则**:围绕用户给的内容**深挖**,**不要切到别的话题**。
**反问类型(按场景挑)**
- 触发瞬间:"你说 X 这件事,最初是哪个具体瞬间触发你想做的?" / "是什么让你觉得这值得讲一条视频?"
- 情绪锚点:"这里面最让你 [生气 / 觉得荒唐 / 觉得有意思] 的是哪个细节?"
- 角度选择:"你想说的是 [角度 a:现象批判] 还是 [角度 b:自我反思] 还是 [角度 c:泛化到普遍]?"
- 受众想象:"你心里想着是说给哪种人听?她/他听完会怎么想 / 怎么转发?"
- 反对意见探测:"如果有人反驳说 [反方观点 X],你会怎么回?"——逼用户先想清楚立场
**反问纪律**
- 一次只问 **1 个**问题(不要塞 3 个连珠炮)
- 最多 `MAX_DEEP_DIVE_TURNS` 轮(默认 4)——超过就主动收敛:"OK 我感觉够了,帮你提议一个角度试试"
- 用户的回答如果含 emoji / 简短 / 不耐烦 → 立刻收敛,不要逼
**收敛输出**
```
我感觉这个角度能做:
[一句话立意:50 字以内]
走法:
- 用 [Pattern X 结构](来自 script_patterns.md
- 钩子:[具体场景 / 句子]
- 主体:[3 个观察是什么]
- 收尾:[MVP 句方向]
粗打分(v0 等权 7 维):ER=X HP=X QL=X NA=X AB=X SR=X SAT=X → composite ≈ X.X
Confidence: 🔴 极低 (你才校准 0/N 篇)
要不要让我先写一份 draft?(yes / 换角度 / 我自己写)
```
用户回 yes → Phase 4 写 draft。
用户说"换角度" → 回 Phase 2A 深挖更多。
用户说"我自己写" → 把 candidate 加进 candidates.md 标 tier1,结束。
### Phase 2B: Mode B — 单问"为什么",触发用户内省
用户给了方向但不具体("想做职场" / "AI 方向" / "婚恋")。**这阶段不调任何热点工具**——是用户内省的窗口,外部信息会污染。
只问一个问题,**单刀直入**
```
为什么想做这个主题?
```
不要问"a/b/c 三种例子"——那是 dump 选项让用户挑,破坏内省。让用户自己组织语言。
**根据用户回答分流**
| 用户答 | 分类 | 行为 |
|---|---|---|
| 含具体经历 / 个人卡点("我自己经常加班" / "我看到 X 让我..." | **真动机** | 转 Mode A 深挖(Phase 2A |
| 抽象热度归因("这话题最近热" / "别人都在做" / "听说能涨粉" | **空动机** | 反问"那你自己对这个话题最有感觉的角度是啥?"——逼出个人 stake;继续空 → 转 Mode C |
| "我也不知道" / "朋友说赚钱" / 模糊推搡 | **真没想法** | 直接转 Mode C |
**反问纪律**:最多 2 轮。第 2 轮还问不出真动机 → 直接转 Mode C,**不要无限挖**。
> 设计意图:Mode B 是"过滤器",不是"工厂"。用户来这里要么暴露真实动机(→ 进 Mode A)要么暴露自己其实没想法(→ 进 Mode C)。两条都比硬要在 Mode B 里产出选题更好。
### Phase 2C: Mode C — 外部素材 + 用户挑 + 回到内省
用户完全没想法(直接显式说 / 从 Mode B 转过来)。**这是唯一默认调热点工具的入口**。
按 [shared-references/data-source-routing.md](../../shared-references/data-source-routing.md) 的路由规则:
1. **拉外部素材**(按 `content_form` 选 trend source):
- `tutorial-builder` / AI 类 → 调 aihot skill
- `opinion-video` / `long-essay` / `podcast` / `other` → 调 trendradar-mcp(如启用)
- `mixed` → 两个都调
- 都不可用 → 走 manual-paste(询问用户:"今天看到啥可以拍的?粘几条 URL/标题")
2. **聊经历兜底**(用户拒绝看外部素材 / 外部素材都不感兴趣):
```
外部素材你都没感觉,那回到你自己。三个开口,挑一个开始讲:
a) 你最近真实接触到的某件具体事?("上周我看到我同事 X..."
b) 你最近读到 / 看到的某条让你想吐槽的内容?("知乎上有个回答..."
c) 你长期琢磨的某个 unsolved 困惑?("我一直没想明白为啥 X..."
随便挑一个开始讲。
```
3. **拉到外部素材后**,用 rubric 粗筛 + 按 content_form 过滤,留 5 条最契合的:
```
今天这 5 个跟你形态契合:
1. [标题 A](来源: trendradar / 微博热搜 / hot_score: 8.5
2. [标题 B](来源: aihot / 模型类 / 精选)
3. ...
你哪个最有感觉?没感觉就回 '都没',我换方向问。
```
4. **用户挑了一条 → 回到内省**:
```
OK [标题 X]。你为啥对这条最有感觉?
是 [angle1] 还是 [angle2] 还是别的?
```
→ 用户答 → 转 Mode A 深挖。
5. **用户回 "都没"** → 转回 Mode C 第 2 步的"聊经历兜底"。
**关键**:热点不是"塞 5 候选让用户挑",是"给材料 + 强制问用户的个人 stake"——AI 不替用户决定哪条最值得做。
### Phase 2A.5: Mode A 灰色场景 — 用户讲了时事话题
Mode A 默认深挖用户经历。但如果**用户讲的本身是时事话题**(产品名 + 时间词 / 人名 + 事件词),按 [data-source-routing.md](../../shared-references/data-source-routing.md) 的"时事判定"规则,**询问**用户要不要拉外部数据作参考:
```
💡 [话题] 是时事——我可以拉一下今天的舆论风向(各平台情绪 + 主要 angles)作参考。
要看吗?回 '看' 我调;回 '不用' 我直接跟你深挖你的角度。
```
用户回 "看" → 调对应 trend source → 把数据 inline 到深挖 context
用户回 "不用" → 标准 Mode A 深挖,不动外部数据。
**永远不主动调**——用户的 angle 优先于外部数据,避免外部信息**带偏**用户视角。
### Phase 2D: Batch Mode(用户显式 `--batch N`
**保留旧 brainstorm 流程**
1. 问 3 个清单问题(兴趣 / 调性 / 红线)—— Batch 模式才问这些
2. 抓热点 + Claude brainstorm 15 候选
3. 用户挑 N
4. 写 N 份 draft 到 scripts/——**每份都走 Phase 4 的段落版格式 + Phase 4.5 自检**line-format + humanizer),不因为批量就跳过
详见 commit history(旧 cheat-seed 的 Phase 1-3)。这是 escape hatch,不是默认。
### Phase 3: 计算 candidate ID + 落候选池
不管 Mode A/B/C 哪条路径,确认角度后:
1. 算 candidate id`sha256("seed-" + 立意 + 触发时间)[:12]`
2. 写一行 entry 到 `candidates.md`(按 [candidate-schema.md](../../shared-references/candidate-schema.md) 格式)
3. 标 `tier=tier1` + `read_status=deep_read`(已经讨论过,不是 skim
### Phase 4: 写 draft
`WITH_DRAFT=yes` → 顺次写到 `scripts/<YYYY-MM-DD>_<id>_<short-title>.md`
**写 draft 前必读** `script_patterns.md` —— 按"结构选型 cheat sheet"对应用户的 topic 选合适结构。如果文件还在抽象骨架阶段(用户没填几个 pattern),就用 starter rubric 对应的通用框架。
**字数**:按 `DRAFT_LENGTH` 派生(基于 `typical_duration_seconds`)。
#### ⚠️ 正文必须是段落版,不是字幕格式(**最常见的生成跑偏**)
模型的训练先验会把"视频脚本"默认写成提词器/字幕的短行格式。**这是错的**——cheat-seed 的 draft 是给用户**改写**的散文稿,不是拍摄字幕。字幕是剪映拍后自动断的,不是写作时的形态。
生成正文时,眼睛盯住下面这个对照:
```
❌ 字幕格式(不要这样写):
你有没有发现
所有审稿人都在说一样的话
你的研究太老套了
但你仔细看
他们引用的全是 5 年前的反应
✅ 段落版(必须这样写):
你有没有发现,所有审稿人都在说一样的话——你的研究太老套了。但你仔细看,他们引用的全是 5 年前的反应。AI 不是新东西,新的是这次大家集体觉醒了。
```
规则:
- **每段 100-300 字**,逗号 / 句号 / 破折号自然连,**不在句子边界硬断行**
- 段与段之间空一行(自然的主题切换才换段)
- 一份 draft 正文一般 3-6 段,**不该有几十个单句行**
#### 格式:
```markdown
# [立意标题]
> ⚠️ **Draft by Claude — 你必须改写后再拍**
>
> 这是脚手架,不是成品。你的语气 / 节奏 / 个人经历无法 AI 生成。
> 改写流程:
> 1. **直接在本文件改写**(同 pathscripts/<...>.md
> - 加你的语气、个人经历、真实金句
> - 砍铺垫、砍模型缩写、砍学术包装
> 2. 改完后跑 `/cheat-predict scripts/<本文件>.md`
> 3. 拍完跑 `/cheat-shoot scripts/<本文件>.md`
**Article ID**: <12 位 hash>
**调性**: [基于讨论得出的,不是清单 Q]
**目标时长**: <state.typical_duration_seconds 转换> 分钟
**目标字数**: <按时长派生>
**结构选型**: [按 script_patterns.md 的 cheat sheet 显式标,如 "metaphor 优先" / "数据反转开场"]
**用到的 patterns**: [编号 + 简短说明]
**讨论种子**: [一句话回顾 deep dive 出来的核心]
---
[draft 正文 —— **段落版**3-6 段,每段 100-300 字,不是单句碎行]
```
`WITH_DRAFT=no`(用户说"我自己写")→ 跳过 Phase 4 + Phase 4.5。
### Phase 4.5: draft 自检 pass(版式 + 去 AI 味)
draft 写完落盘后、**在展示给用户前**跑两步自检。**顺序固定:先 4.5a 修版式,再 4.5b 去 AI 味**——humanizer 处理散文,喂它字幕格式的碎行会乱。
**为什么安全**(不污染校准):cheat-seed 的 draft 不是被预测/发布的东西——用户改写后、cheat-predict 打分的是**用户最终稿**。这两步只是给用户更干净的起点。
#### Phase 4.5a: line-format 自检(字幕格式 → 段落版)
Phase 4 的散文指令 + ❌/✅ 对照已经在生成时压先验,但生成仍可能跑偏。这一步是**确定性兜底**:
```bash
# 只看正文段(--- 分隔线之后)
body=$(awk '/^---$/{f=1;next} f' scripts/<id>.md)
line_count=$(printf '%s\n' "$body" | grep -c .) # 非空行数
char_count=$(printf '%s' "$body" | wc -m | tr -d ' ')
avg_chars_per_line=$(( char_count / (line_count > 0 ? line_count : 1) ))
```
判定:**`avg_chars_per_line < 15` 且 `line_count >= 8`** → 判定为字幕格式 → **自动重排**:
- 把句子边界的硬断行合并回自然段落
- 按主题切换分 3-6 段,每段 100-300 字
- 用 Edit 替换正文段(header 不动)
- 在 Phase 5 输出里标一行:"📐 检测到字幕格式,已重排为段落版"
不命中 → 跳过,正文已经是段落版。
#### Phase 4.5b: humanizer 去 AI 味
`HUMANIZE_DRAFT=on`(默认)—— 用 `humanizer` skill 过一遍。Claude 自己写的初稿天然带 AI tellsem-dash 滥用 / rule of three / "inflated" 词汇 / 空泛归因 / -ing 浅层分析),这一步把它们清掉。
步骤:
1. 检查 `humanizer` skill 是否可用(`~/.claude/skills/humanizer/` 存在):
- 不可用 → 跳过 4.5b,在 Phase 5 输出里加一行"humanizer 未装,draft 是原始 AI 版——`git clone https://github.com/blader/humanizer` 到 ~/.claude/skills/ 可启用自动去 AI 味)"
2. 可用 → 通过 Skill tool 调 `humanizer`**只传 draft 正文**`---` 分隔线之后、4.5a 已重排好的段落版),**绝不传 header**
- header 的 `⚠️ Draft by Claude — 你必须改写后再拍` 警告是**有意的脚手架标记**,不是要 humanize 的散文
- **voice calibration**:如果用户有历史脚本(`videos/*/script.md`)或填过 `script_patterns.md`,把最近 1-2 份作为 humanizer 的 voice 参考样本一起传——让它往"**这个用户的声音**"靠,而不是"通用人声"
3. humanizer 返回去 AI 味的正文 → 用 Edit 替换 draft 文件的正文段(header 不动)
4. 记录 humanizer 报告的"修了哪些 tell"(如 `em-dash 滥用 ×3 / rule of three ×2 / inflated 词汇: "深刻" "本质上"`),Phase 5 输出里展示
**纪律**
- humanizer 是**去 AI 味**,不是**替用户改写**。它让 draft 不那么像机器写的,但**仍不是用户的声音**——header 的"必须改写"警告依然成立,Phase 5 输出要重申
- 如果 humanizer 把某句改得偏离了 `结构选型` / 用到的 pattern → 以 pattern 为准,那句回滚(pattern 是和用户讨论定的,humanizer 不该推翻)
- humanizer **不负责版式**——断行问题在 4.5a 已经修完,humanizer 拿到的已是段落版
### Phase 5: 输出"下一步" + 询问继续
```
✅ Draft 写完:scripts/2026-05-04_<id>_<short>.md
📐 版式自检:通过(段落版) ← 或"检测到字幕格式,已重排为段落版"
🧹 humanizer 过了一遍:修了 em-dash 滥用 ×3 / rule of three ×2 / inflated 词汇 2 处
draft 现在不那么"机器味"了——但这仍是脚手架,不是你的声音)
接下来你可以:
- 改写这份 draft(直接在原文件改)—— 加你的语气、经历、真实金句
- 改完跑 "打分这篇 scripts/<...>.md" 看 7 维评分
- 决定要拍 → "启动预测 scripts/<...>.md"
下一篇你想做什么?
(直接告诉我具体经历 / topic,或者说"今天就这样"结束)
```
> humanizer 那行只在 `HUMANIZE_DRAFT=on` 且 skill 可用时出现。未装时换成一行提示如何启用。
用户说"今天就这样" → 结束 cheat-seed。
用户给新 topic → 回 Phase 1 重新分流。
## Key Rules
1. **AI 不主动开放问**——只在用户纯触发 `/cheat-seed` 时问一次入口问题,其他时候**等用户给输入再深挖**
2. **一次一个选题**——默认 Mode A/B/C 都给 1 个建议;用户主动要批量才走 Batch
3. **反问纪律**:一次问 1 个,最多 4 轮,用户不耐烦立刻收敛
4. **深挖围绕用户给的话题**,不要切到别的——你说"开会被领导骂",AI 不该问"那你最近有没有觉得 AI 让大家..."这种平行话题
5. **写 draft 必须读 script_patterns.md**——按用户已有 pattern 选结构
6. **draft 是脚手架**——header 加醒目警告"必须改写"
7. **humanizer 只去 AI 味,不替用户改写**——Phase 4.5b 让 draft 不那么机器味,但它仍不是用户的声音;"必须改写"的警告不因为过了 humanizer 就失效
8. **正文是段落版不是字幕格式**——生成时盯 Phase 4 的 ❌/✅ 对照;Phase 4.5a 用 `avg_chars_per_line < 15 且行数 ≥ 8` 做确定性兜底,命中就重排。字幕是剪映拍后自动断的,不是写作时的形态
## Refusals
- 「跳过深挖,直接写 draft」 → 询问"你想直接给主题让我写吗?OK 但 draft 质量可能差——我不知道你的角度。给我一句话立意我就写"
- 「AI 替我决定 topic」 → 拒绝。Mode A/B/C 路径里 AI 永远只**呈现外部素材** + **问用户角度**,不替用户拍板"做哪条"
- 「Mode B 我懒得回答为什么,直接给我 5 个候选吧」 → 拒绝。Mode B 的"为什么"是过滤器——你答不出来就不该用 Mode B 的方向。要么进 Mode A 给具体经历,要么进 Mode C 我帮你找素材
- 「Mode A 时直接帮我拉热点,不问我同意」 → 拒绝。Mode A 用户已经有 angle,未经允许拉外部数据会污染他的视角。详见 [data-source-routing.md](../../shared-references/data-source-routing.md)
- 「一次写 5 个 draft」 → 不在默认流程。用户必须显式 `--batch 5`
- 「我懒得改写,直接拍 AI draft」 → 警告"AI 直接生成的稿子拍出来 ER 偏低,会污染你的校准数据",但用户坚持也允许(标 `unmodified_ai_draft: true`
## Integration
- 上游:`/cheat-init` Phase 5 末尾在 `pool_status=none + calibration_samples=0` 时主动询问"现在跑 /cheat-seed"
- 上游:`/cheat-recommend` 在 candidates 空时引导文案中提及 `/cheat-seed`
- 上游:`/cheat-status` 在 `pool_status=none + 距 init >24h` 时提示"还没拍——跑 /cheat-seed"
- 下游:用户的 candidate → candidates.mdtier1,已 deep_read
- 下游:(默认)draft → Phase 4.5 humanizer 去 AI 味 → scripts/<id>.md → 用户改写 → /cheat-predict
- 可选依赖:[`humanizer`](https://github.com/blader/humanizer) skillMIT,外部项目)。装在 `~/.claude/skills/humanizer/` 时 Phase 4.5 自动启用;未装则优雅跳过。**不打包进 cheat-on-content**——用户自己 clone
- 与 `/cheat-trends` 区别:cheat-seed 是**讨论 + 写 draft**(重 conversation);cheat-trends 是**多 adapter 抓 + 粗打分**(重 fetch)。两者目的不同,不互相替代。
+213
View File
@@ -0,0 +1,213 @@
---
name: cheat-shoot
description: 登记一条视频已拍摄。**建 video folder + 询问实际拍摄稿是否与 scripts/<id>.md 一致 + buffer +1**。与 cheat-publish 配对:拍了进队列,发了出队列。触发词:"拍了"/"拍了 X"/"shot"/"shot it"/"已拍 X"/"录完了"。
argument-hint: <scripts-path-or-id>
allowed-tools: Bash(*), Read, Write, Edit, Glob
---
# /cheat-shoot — 登记拍摄完成 + 建 video folder + (改稿则) 触发 v2 预测
把视频从"已写预测、未拍摄"状态推进到"已拍摄、未发布"状态。这一步:
1. **建 `videos/<同 id>/`** 目录(之前没有的话)
2. **询问用户**"实际拍摄时用的稿子和 `scripts/<id>.md` 一致吗?"
3. 算 diff——超过 V2_TRIGGER_THRESHOLD (默认 30%) → **delegate 到 `/cheat-predict — mode: v2`** 在原 prediction 文件 append `## 预测 v2`
4. 把 video folder 加进 state.shoots 队列,buffer +1
cheat-shoot 自己**不**写预测内容——所有预测落盘逻辑在 cheat-predict。cheat-shoot 只负责检测改稿 + 派发。
为什么单独一个 skill
- buffer 警戒系统需要明确区分"拍了" vs "发了"。视频可以批量拍(一天拍 5 条),分散发(每天发 1 条)
- "实际拍摄稿" ≠ "pre-shoot 草稿"是常态。这一步是把 diff 显式化、触发 v2 重判、采集"用户改稿 pattern"信号的入口
- v2 预测 vs v1 预测的差异本身就是 rubric 升级证据——比如 v1 给 ER=4,v2 给 ER=5(用户改稿改高了 hook 强度),就告诉 rubric "这个用户的 ER 阈值跟我现在公式不一致"
## Overview
```
[用户:拍了 scripts/2026-05-04_abc123_停止期待.md]
[Phase 0: 解析路径 + 验证 prediction 已存在]
[Phase 1: 检查是否已登记(避免重复)]
[Phase 2: 建 videos/<id>/ + 询问"实际拍摄稿一致吗?"]
[Phase 3: 写 videos/<id>/script.md]
[Phase 4: append state.shoots]
[Phase 5: 输出 buffer 状态]
```
## Constants
- **REQUIRE_PREDICTION = true** — 拍前必须先有 v1 prediction 文件
- **V2_TRIGGER_THRESHOLD = 0.30** — normalize 后 char-level diff 超过 30% → 默认建议 v2 重判;低于 30% 询问用户是否仍要 v2
- **DIFF_METRIC = char_levenshtein_normalized****默认**)—— 通过 [`tools/diff_pct.py`](../../tools/diff_pct.py) 调用:先 normalize(去 markdown header / 分隔线 / 列表标记 / 装饰标点 / 折叠所有空白),再算 char-level Levenshtein / max(len_a, len_b)。preferred backend `rapidfuzz`fallback `difflib.SequenceMatcher`(stdlib,永远可用)。**旧版 line-level 在口语化转录场景误报严重**draft 长 markdown 句 vs whisper 转录的短断句,内容几乎不变但 line-level 算出 ~200% diff)—— PR #14 修复
- **DIFF_METRIC=lines** —— legacy fallback:当 python3 完全不可用或 tools/diff_pct.py 找不到时降级到 `diff -u | grep '^[+-]' | wc -l` 算法
## Inputs
| 必填 | 来源 |
|---|---|
| `<scripts-path-or-id>` | 用户参数;缺失则询问 |
| `.cheat-state.json` | 状态文件 |
| `scripts/*.md` | pre-shoot 草稿 |
| `predictions/*.md` | 验证对应预测存在 |
## Workflow
### Phase 0:解析 + 验证
1. 解析用户给的路径——支持几种形态:
- 完整路径 `scripts/2026-05-04_abc123_停止期待.md`
- 简写 `2026-05-04_abc123_停止期待`
- id 简写 `abc123` → glob `scripts/*_abc123_*.md` 找匹配
2. 验证 `scripts/<id>.md` 存在:不存在 → 报错"找不到 pre-shoot 草稿"
3. 验证有对应 prediction `predictions/<同名>.md`
- 不存在 → **拒绝登记**,提示"先跑 /cheat-predict 写预测,否则违反盲预测原则——你不能拍完才写预测,那等于事后看了画面写"
- 存在 → 通过
### Phase 1:检查重复
`.cheat-state.json`,检查 `shoots[]` 是否已含此 id
- 已存在 → 警告"已登记过(X 天前)。是要重新登记,还是要用 /cheat-publish 发布?"
- 不存在 → 进入 Phase 2
### Phase 2:建 video folder + 询问稿子一致性
1. 建目录 `videos/<id>_<short>/`(同 scripts/ + predictions/ 的命名)
2. **询问用户**
```
拍 「<title>」 的时候,你实际用的稿子和 scripts/<id>.md 一致吗?
a) 一致——按草稿拍的
b) 改了一些——你能给我看下实际拍摄稿吗?我重新打分一次(v2 预测)
c) 大改了,基本是另一条 → 走 _redo 流程:
scripts/<id>_redo.md → 重新 cheat-predict → 再 cheat-shoot(原 prediction 留档脱钩)
```
### Phase 3:写 videos/<id>/script.md + (b 路径) 触发 v2 预测
**a 路径(一致)**
- `cp scripts/<id>.md → videos/<id>/script.md`
- `script_consistency = consistent`
- 不重判,进 Phase 4
**b 路径(改了)**
1. 询问用户实际拍摄稿——粘贴文本 / 文件路径 / 转录文件
2. 若用户提供 → 写入 `videos/<id>/script.md`
3. 若用户没保留(即兴)→ 标 `script_lost`,写占位文件 + 警告"v2 重判跳过——下次建议留稿(哪怕 voice memo 转录)",进 Phase 4
4. 提供了的话:算 diff
```bash
# 解析 cheat-on-content 源码根(cheat-shoot 是 symlink 装的)
SKILL_REAL="$(readlink -f ~/.claude/skills/cheat-shoot 2>/dev/null || readlink ~/.claude/skills/cheat-shoot 2>/dev/null)"
if [[ -n "$SKILL_REAL" ]]; then
REPO_ROOT="$(cd "$SKILL_REAL/../.." && pwd)"
DIFF_TOOL="$REPO_ROOT/tools/diff_pct.py"
fi
if [[ -n "${DIFF_TOOL:-}" && -f "$DIFF_TOOL" ]] && command -v python3 >/dev/null 2>&1; then
# 默认 char-level Levenshtein on normalized textrapidfuzz preferred, difflib fallback
diff_pct=$(python3 "$DIFF_TOOL" "scripts/<id>.md" "videos/<id>/script.md")
else
# legacy line-level fallback——只在 python3 或 diff_pct.py 都不可用时用
added=$(diff -u scripts/<id>.md videos/<id>/script.md | grep -c '^+')
removed=$(diff -u scripts/<id>.md videos/<id>/script.md | grep -c '^-')
total_orig=$(wc -l < scripts/<id>.md)
diff_pct=$(( (added + removed) * 100 / total_orig ))
echo "⚠️ fallback 到 line-level diff——口语化转录会 inflate diff_pct,可能误触发 v2"
fi
```
**为什么 normalize + char-level**line-level diff 在创作者真实场景(draft 是 markdown 长句、拍摄稿是 whisper 转录的口语化短行)算出 ~200% 差异但内容几乎不变。char-level Levenshtein 在 normalize 后稳定反映**内容**差异,而非格式差异。详见 [`tools/diff_pct.py`](../../tools/diff_pct.py) + `tools/diff_pct_test.sh`3 fixture 在两个 backend 上全过)。
5. **判定 v2 触发**
- `diff_pct >= 30` → 默认建议 v2 重判,**主动调用** `/cheat-predict — mode: v2 — prediction-file: predictions/<id>.md` 传 `videos/<id>/script.md` 作 input。cheat-predict 走 v2 模式 append `## 预测 v2`
- `diff_pct < 30` → 询问用户:"只改了 N% 的内容,要重判吗?默认不(v1 预测仍有效)"。用户说要 → 同上调用;用户说不 → 跳过 v2,继续 Phase 4
6. cheat-predict 完成 v2 落盘后,控制权回到 cheat-shoot 进 Phase 4
**c 路径(大改)**
- 不写 `videos/<id>/script.md`,提示走 `_redo` 流程
- 退出 cheat-shoot(不进 Phase 4
### Phase 4state 更新
```json
{
"shoots": [
...,
{
"video_folder": "videos/2026-05-04_abc123_停止期待/",
"prediction_file": "predictions/2026-05-04_abc123_停止期待.md",
"scripts_path": "scripts/2026-05-04_abc123_停止期待.md",
"shot_at": "<ISO timestamp>",
"script_consistency": "consistent" | "modified" | "lost",
"script_diff_pct": <0-100 int 或 null>,
"v2_prediction_written": <true/false>,
"script_hash_at_shoot": "<sha256:12 of videos/<id>/script.md>"
}
]
}
```
`v2_prediction_written: true` 表示 prediction 文件里现在有 `## 预测 v2` 段,cheat-retro 应读 v2 算偏差;`false` 表示沿用 v1。
### Phase 5:输出 buffer 状态
读完 state 后立即算 buffer + 颜色(按 [cadence-protocol.md](../../shared-references/cadence-protocol.md) 的派生规则):
```
✅ 已登记拍摄:videos/2026-05-04_abc123_停止期待/
预测文件:predictions/2026-05-04_abc123_停止期待.md
📦 当前 buffer:3 篇(🟢 绿色,正常)
按你的 cadence(隔日更)= 6 天 buffer,节奏稳定。
下一步:拍其他候选 / 等下个发布日 / 不动
```
如果 buffer 颜色变了(如从绿到蓝)→ 高亮提醒:
```
📦 当前 buffer:6 篇(🔵 蓝色,**积压**)
⚠️ 建议暂停拍摄,全力发布存货 + 复盘。
按你的 cadence(日更)= 6 天预备,已超过健康上限。
```
## Key Rules
1. **不写 prediction**——拍了 ≠ 发了。预测在 /cheat-predict 锁,拍只是事件
2. **不动 video folder 内容**——script.md / draft-v0.md 都不改
3. **必须先有 prediction**——否则违反盲预测(拍完看了画面再写预测 = 数据泄漏到判断)
4. **buffer 计算实时**——每次 shoot / publish 后立刻重算,state.shoots 是真值
5. **支持批量**:用户可以一天连说 "拍了 X / 拍了 Y / 拍了 Z" 三次连续登记
## Refusals
- 「拍了 X,但我从来没跑过 cheat-predict」 → 拒绝。v1 预测**必须拍前写**——拍完才写预测会被画面诱导事后修改。请先 /cheat-predict 写 v1 再来 /cheat-shoot。(v2 重判是另一回事——v1 已存在 + 拍后改稿才允许)
- 「我没有 video folder,我直接拍的」 → 询问用户 → 帮他建 video folder + 提示下次走完整流程;登记时标 `ad_hoc: true`
- 「我改稿了但你直接覆盖 v1 吧,别留 v2 段」 → 拒绝。v1 是档案,v2 才是当前判断——append 不覆盖。两段一起留是 rubric 学习的关键证据
## Integration
- 上游:`/cheat-predict` 写完 prediction → 用户拍摄 → `/cheat-shoot` 登记
- 下游:`/cheat-publish` 发布时把对应项从 state.shoots 移除
- `/cheat-status` 看板的 buffer 数字直接来自 `state.shoots.length`
- `/cheat-recommend` 看 buffer 颜色调推荐策略
- SessionStart hook 看 buffer 颜色决定报告第一行
## state.shoots 数据结构
```json
{
"shoots": [
{
"video_folder": "videos/2026-05-04_abc123_停止期待/",
"prediction_file": "predictions/2026-05-04_abc123_停止期待.md",
"shot_at": "2026-05-04T18:30:00+08:00",
"ad_hoc": false // true if user shot without going through full flow
}
]
}
```
按 `shot_at` 升序——最早拍的在前面。`/cheat-status` 显示最早一项的 days-since-shoot 警告(避免有视频拍了 30 天没发)。
+162
View File
@@ -0,0 +1,162 @@
---
name: cheat-status
description: cheat-on-content 的状态看板。显示当前模式 / rubric 版本 / 校准进度 / 待复盘 / pool 状态 / 是否该升级 SQLite / 是否该 bump rubric。**任何时候都可调,无副作用**。触发词:"状态"/"看板"/"status"/"我现在该做什么"/"进度怎么样"。
allowed-tools: Bash(*), Read, Glob, Grep
---
# /cheat-status — 状态看板
读 state file + 扫描用户项目 → 汇总当前进度 → 输出"今天该做什么"清单。
## Overview
```
[用户:状态]
[Phase 1: 读 .cheat-state.json + 扫文件系统]
[Phase 2: 计算派生指标]
[Phase 3: 检测建议触发器(升级 / bump / 清算)]
[Phase 4: 输出看板]
```
## Constants
- **SQLITE_UPGRADE_THRESHOLD = 30** — calibration_samples 达到 N 时建议升 SQLite
- **CLEANUP_LINE_THRESHOLD = 600** — rubric_notes.md 行数超 N 时建议清算
- **STALE_PREDICTION_DAYS = 30** — in_progress prediction 超 N 天未发布提示清理
## Inputs
| 来源 | 用途 |
|---|---|
| `.cheat-state.json` | 主要状态 |
| `predictions/*.md` | 校准样本数 / pending retros |
| `candidates.md` | 候选池规模 |
| `rubric_notes.md` | 行数 / 当前版本 |
| `.cheat-cache/usage.jsonl`(如有) | meta-logging 数据,用于"距上次 bump 多少次预测" |
## Workflow
### Phase 1: 读状态
```python
state = read_json('.cheat-state.json')
if not state:
return "你还没初始化。请先跑 /cheat-init。"
predictions = glob('predictions/*.md')
candidates_count = parse_candidates_md_entries()
rubric_lines = wc -l rubric_notes.md
```
### Phase 2: 派生指标
| 指标 | 算法 |
|---|---|
| **Buffer 数** | `len(state.shoots)` |
| **Buffer 颜色** | 按 [cadence-protocol.md](../../shared-references/cadence-protocol.md) 派生:`buffer_days = buffer_count × target_publish_cadence_days``<1 红 / 1-2 橙 / 3-5 绿 / >5 蓝`。如 `target_publish_cadence_days=null` → 颜色禁用 |
| **Confidence 等级** | 按 [state-management.md confidence 表](../../shared-references/state-management.md) 派生:从 `calibration_samples` 整数派生 emoji + 标签 |
| **最早一拍至今天数** | `now - state.shoots[0].shot_at`,用于警告"拍了 N 天没发" |
| 校准样本数 | predictions 中含完整复盘段(实绩数据非空)的文件数 |
| 待复盘 | state.pending_retros 中已过 RETRO_WINDOW_DAYS 的 |
| 池大小 | candidates.md 中 tier!=skip 的 entry 数 |
| 上次 bump 至今几次预测 | predictions 中 published_at > state.last_bump_at 的数量 |
| 同向偏差队列 | state.consecutive_directional_errors |
| in_progress 陈旧度 | now - state.in_progress_session.started_at(如有) |
### Phase 3: 检测建议触发器
按优先级(高→低)逐项检查:
1. **Buffer 颜色 = 🔴 红** → 第一行高优先级警戒:"buffer 已 0/1 篇,下个发布日可能断更——今天必须拍 ≥1 条。说'推荐选题'我只推 top 1 稳分(不推实验性)"
2. **Buffer 颜色 = 🔵 蓝** → 高优先级提示:"buffer 已 N 篇积压。**暂停拍摄**,先发存货 + 复盘。说'已发布 ...'我帮你出队"
3. **state.shoots 中最早一项 shot_at > 14 天** → "你有视频拍了 N 天还没发——议题时效流失风险,建议尽快发或弃稿"
4. **in_progress 陈旧** (>= STALE_PREDICTION_DAYS) → 高优先级提示"清理或 publish"
5. **待复盘 ≥ 1** → 高优先级"今天该复盘 X 篇"
6. **`pool_status=none` + `calibration_samples=0` + 距 init >24h** → "🌱 你 init 完已经 N 天但还没拍——是因为没选题吗?跑 /cheat-seed 5 分钟拿 5 个候选 + 5 个 draft" 高优先级
7. **Claude 判断系统性偏差信号**(**不是死磕 ≥3 同向**) → 提示"建议跑 /cheat-bump"
- **默认参考**:连续 ≥3 次同向偏差
- **但 Claude 可以更早**:1 次极端偏差(≥10x)或 2 次同向 + 评论区强反向证据
- **也可以更晚**:3 次同向但每次幅度都 <25%(可能只是噪声)
- 提示时显式标注:"本次是 [default-aligned] / [judgment-driven]"
8. **calibration_samples 跨入新 confidence 等级**0→1, 2→3, 5→6, 10→11, 20→21)→ 提示"🎉 confidence 升级:<旧等级> → <新等级>。bucket 中枢精度从 ±X% 提到 ±Y%"。**仅作通知,无任何用户必须确认的操作**——所有 skill 都已经按 calibration_samples 自动调整
9. **calibration_samples 跨过 5** → "你的 rubric 形态可以第一次正式 bump 了。回顾 rubric_notes.md 看观察记录段是否有 ≥3 样本支持的 pattern → 跑 /cheat-bump"
10. **calibration_samples 跨过 10** → "可以跑 /cheat-bump --bucket-only --scheme percentile 让 bucket 边界改用 percentile(永远自洽)"
11. **calibration_samples 跨过 SQLITE_UPGRADE_THRESHOLD** 且 data_layer=markdown → "建议跑 tools/md-to-sqlite.py"planned — batch 3, not yet available
12. **rubric_notes.md 行数 > CLEANUP_LINE_THRESHOLD** → "建议清算观察段(手动或下次 bump 触发)"
13. **calibration_samples ≥ 5 + pool_status=none** → "可以开始建立选题池了"
14. **calibration_samples ≥ 15 + pool_status=none** → "强烈建议建池:/cheat-trends 或手动建 candidates.md"
15. **state.hooks_installed=false** → "你的 immutability 是君子协定,建议跑 /cheat-init 装 hook"
16. **state.last_bump_self_audited=true** → "上次 bump 是自审。建议配置 mcp__llm-chat__chat 后下次 bump 走外部审"
17. **state.rubric_form_mismatch=true** → "你的 content_form 不是 opinion-video,用了内置观点 rubric。前几篇预测会更不准,下次 bump 时建议自行调整权重适配你的形态"
18. **state.benchmark_status=pending** → "🎯 你 init 时答应等下找对标账号但还没找。跑 /cheat-learn-from 导入 ≥3 条对标视频,工具就有 anchor 了"
19. **state.benchmark_status=imported + Claude 判断用户数据信号已超过 benchmark** → "📊 你的真实数据已经成为主信号,benchmark 影响淡出"
- **默认参考**calibration_samples ≥ 10
- **但 Claude 可以更早**N=5 但用户的 (打分, 实绩) 配对里出现 ≥3 条与 benchmark pattern 不一致的——说明你的账号已经走出对标的路径
- **也可以更晚**:N=15 但用户的样本都很相似,没足够多样性 → benchmark 仍有信号价值
- 提示是**通知不是 gate**——benchmark.md 永远保留作 sanity checkcheat-seed 仍可读
### Phase 4: 输出看板
```
🎛️ cheat-on-content 状态(更新于 2026-05-04 15:00
内容形态:opinion-video / 时长 3-5min / cadence: 隔日更
当前 rubricv2 (上次 bump: 2026-04-22)
校准样本:18 篇
Confidence: 🟢 较高 (中枢 ±15%rubric 形态稳定)
Baseline: 4.2w 中位数
📦 Buffer3 篇(🟢 绿色)
按你的 cadence (隔日更)= 6 天 buffer,节奏稳定
📊 进度条
[█████████████░░░░░] 18 / 30 → SQLite 升级建议门槛
[██████████░░░░░░░░] 18 / 10 → percentile 桶可用(已超过门槛)
🎬 待办(按紧急度)
🚨 复盘 1 篇(已过 T+3d)
- predictions/2026-05-01_db063817_你已不在关系里.mdT+3d 到了)
⚠️ 同向偏差 3 次(high, high, high)→ 建议 /cheat-bump
💤 in-progress prediction 已陈旧 35 天
- predictions/2026-04-01_xxx.md → 是已发了忘登记?还是弃稿?
🔥 候选池
- candidates.md: 27 条(tier1: 12, tier2: 9, tier3: 6
- 距上次抓热点: 4 天 — 可以再跑 /cheat-trends
📈 健康度
- rubric_notes.md: 412 行(健康,<600 警戒线)
- hooks_installed: ✅
- external audit configured: ❌ → 建议配 mcp__llm-chat__chat
下一步建议(按推荐优先级):
1. /cheat-retro predictions/2026-05-01_db063817_你已不在关系里.md ← 最紧急
2. /cheat-bump ← 同向偏差 3 次的处理
3. 处理陈旧 in-progress(手动或回 "清理 in-progress"
完整的命令清单见主 SKILL.md。
```
输出风格:**直白、具体、可点击**。每个建议附确切的命令——用户应能 copy-paste 直接执行。
## Key Rules
1. **无副作用**。读多写零。任何状态修改是其他 skill 的事
2. **不假装数据可用**。state file 字段缺失 → 显式标"未知",不猜
3. **建议带优先级**。10 个建议同时显示用户会麻木——按紧急度排
4. **每个建议附命令**。不能只说"该 bump 了"——要给 `/cheat-bump --propose "..."` 的精确入口
## Refusals
- 「顺便帮我自动跑一下 retro」 → 拒绝。status 是只读,retro 是另一个动作(避免一次操作做两件事)
- 「我不想看 rubric_notes 行数,太琐碎」 → 输出仍包含但折叠到底部"健康度"区——状态信息的存在让用户在出问题前可见
## Integration
- 上游:所有其他 skill 完成时更新 .cheat-state.jsonstatus 是这些更新的可视化
- 下游:每个建议都路由到具体子 skill
- meta-logging hook(如启用) → 写 usage.jsonlstatus 用它算"距上次 X 多少次"
+199
View File
@@ -0,0 +1,199 @@
---
name: cheat-trends
description: 从配置的热点源(HN / Reddit / YouTube trending / B 站热门 / 等)抓今天的热门话题,去重 + 粗打分 + 写入 candidates.md。**绝大部分人没有候选池——这是让"我没素材"问题在 onboarding 第二步就消失的钥匙**。触发词:"抓热点"/"fetch trends"/"今天有什么可做的"/"trending now"/"找选题"。
argument-hint: [— sources: <comma-separated>] [— max-per: 20]
allowed-tools: Bash(*), Read, Write, Edit, Glob, WebFetch, Skill
---
# /cheat-trends — 热点抓取
多 adapter 模式:读各 `trend-sources` adapter 的输出 → 去重 → 粗打分 → 写入 `candidates.md`
## Overview
```
[用户:抓热点]
[Phase 0: 读 .cheat-state.json 拿 enabled adapters]
[Phase 1: 对每个 adapter 调 fetch]
[Phase 2: normalize 到 candidate-schema]
[Phase 3: 去重(vs candidates / predictions / trends-history]
[Phase 4: 对每个新 item 粗打分(调 cheat-score 内联逻辑)]
[Phase 5: 排序 + 询问用户哪些加入 candidates.md]
[Phase 6: 写入 + 更新 trends-history.jsonl 缓存]
```
## Constants
- **TREND_SOURCES = ["manual-paste"]** — 启用的 adapter 列表(默认仅 manual-paste,最稳)
- **LOOKBACK_HOURS = 24** — 抓最近 N 小时的热点
- **MAX_PER_SOURCE = 20** — 每个 adapter 最多 N 条
- **DEDUPE = true** — 去重开关
- **AUTO_SCORE = true** — 抓回来后自动调 cheat-score 粗打分
- **MIN_COMPOSITE_TO_SUGGEST = 6.0** — 低于此分的不推荐用户加入候选池(仍写入 trends-history 避免下次重复推)
> 💡 调用时覆盖:`/cheat-trends — sources: manual-paste,aihot,weibo-hot — max-per: 10`
## Inputs
| 必填 | 来源 |
|---|---|
| `.cheat-state.json` | 默认 sources |
| `adapters/trend-sources/<name>.md` | 各 adapter 的实现描述 |
| `candidates.md` | 去重对照 |
| `predictions/*.md` | 去重对照(已发的不再推) |
| `.cheat-cache/trends-history.jsonl` | 历史抓取去重缓存 |
## Workflow
### Phase 0: 读启用的 adapters
```python
# 伪代码
state = read('.cheat-state.json')
enabled_adapters = args.sources or state.get('enabled_trend_sources', ['manual-paste'])
```
如 enabled_adapters 为空 → 输出引导:
```
你目前没有启用任何热点源。
最快配法:
- 临时跑:/cheat-trends — sources: manual-paste,aihot
- 永久启用:编辑 .cheat-state.json 的 enabled_trend_sources 数组
可用 adapter(详见 adapters/trend-sources/):
- manual-paste(默认,永远能用)
- aihotAI 热点聚合,无需 key
- weibo-hot(微博热搜,无需 key
- zhihu-hot(知乎热榜,无需 key
- trendradar-mcpTrendRadar MCP 服务,需配置)
```
### Phase 1-2: 对每个 adapter 调 fetch + normalize
对每个 adapter,读其 `adapters/trend-sources/<name>.md` 中描述的 fetch 接口(实际是 Bash 调底层 Python / shell / WebFetch):
| Adapter | 实现机制 |
|---|---|
| `manual-paste` | 询问用户:"粘贴你今天的候选 URL/标题列表(每行一条)" → 解析每行,对 URL 做 WebFetch 拓展 snippet |
| `aihot` | 读 adapters/trend-sources/aihot.md 描述的 fetch 接口 |
| `weibo-hot` | 读 adapters/trend-sources/weibo-hot.md 描述的 fetch 接口 |
| `zhihu-hot` | 读 adapters/trend-sources/zhihu-hot.md 描述的 fetch 接口 |
| `trendradar-mcp` | 读 adapters/trend-sources/trendradar-mcp.md 描述的 fetch 接口 |
每个 adapter 输出符合 [candidate-schema.md](../../shared-references/candidate-schema.md) 的 items。
**优雅降级**:单 adapter 失败(API key 缺失 / 端点 503 / cookie 失效)→ skip 该 adapter,**不抛异常**,在汇总里说明:
```
✅ aihot: 拉到 18 条
✅ weibo-hot: 拉到 15 条
✅ zhihu-hot: 拉到 12 条
⚠️ trendradar-mcp: 跳过(MCP 服务未配置——配置见 adapters/trend-sources/trendradar-mcp.md
```
### Phase 3: 去重
按 [candidate-schema.md](../../shared-references/candidate-schema.md) 的"去重协议"
1. 对每个 item 算 id`sha256(source_type + normalized_title + url_path)[:12]`
2. 检查 `candidates.md` 已含此 id → 跳过
3. 检查 `predictions/*.md` 已含此 id → 跳过
4. 检查 `.cheat-cache/trends-history.jsonl` 已含此 id 且 `rejected_at != null` → 跳过
去重统计写到汇总报告里。
### Phase 4: 粗打分
`AUTO_SCORE=true` 时,对每条新 item
1. 用 item 的 `snapshot_text` 作为输入
2. 按当前 rubric 给 7 维打分(**不**调 `/cheat-score` 子 skill 走 IOinline 复用打分逻辑)
3. 算 composite
4. 给一句 rationale
**注意**:粗打分 ≠ 正式预测。预测必须基于最终稿(用户改过的),这里的打分只是"是否值得展开写"的粗筛。
`AUTO_SCORE=false` 时,items 写入 candidates.md 时 composite=null,需要后续手动 `/cheat-score`
### Phase 5: 排序 + 询问
按 composite 降序,过滤掉 composite < `MIN_COMPOSITE_TO_SUGGEST` 的:
```
🔥 抓热点完成。各源拉取统计:
- manual-paste: 5 条(用户输入)
- aihot: 18 条
- weibo-hot: 15 条
跳过 trendradar-mcpMCP 服务未配置)
去重后剩 27 条新 item。
粗打分后 12 条 composite ≥ 6.0
| # | 标题 | source | composite | bucket | rationale |
|---|---|---|---|---|---|
| 1 | 为什么我们都讨厌主动联系朋友 | aihot | 8.4 | 30-100w | ER+QL 双 5AB 普适 |
| 2 | "她不一样"的一千种变体 | weibo-hot | 8.1 | 30-100w | MS 候选维度高 |
| 3 | ...... |
哪些加入 candidates.md
- 全部加 → 回 "all"
- 选几个 → 回 "1, 3, 5"
- 都不要 → 回 "none"(这些会被记到 trends-history 避免下次重复推)
```
### Phase 6: 落盘
用户响应后:
1. 选中的 items → 按 [candidate-schema.md](../../shared-references/candidate-schema.md) 的"Markdown 表示"格式追加到 `candidates.md`
2. 所有抓回来的 items(不管选中与否)→ append 到 `.cheat-cache/trends-history.jsonl`
```jsonl
{"id": "...", "title": "...", "source": "...", "snapshot_at": "...", "rejected_at": null|"<ISO>", "fetched_at": "<ISO>"}
```
### Phase 7: 状态更新
```json
{
"last_trends_run_at": "<ISO>",
"last_trends_added_count": 5
}
```
## Key Rules
1. **不抛异常**。单 adapter 失败 → skip + 报告。多 adapter 全失败 → 报错"所有源都失败",附排查指引
2. **manual-paste 永远在**。即使其他所有 adapter 都坏了,manual-paste 模式必须能跑——它是兜底
3. **去重是硬约束**。同 id 不重复推;用户拒绝过的 6 个月内不再推
4. **粗打分要诚实标注**。在 candidates.md 的 entry 里标 `composite (rough, snapshot-based)`,避免与 prediction 的精打分混淆
5. **不直接进 predictions/**。trends 只产 candidatespredict 是另一个动作
## Refusals
- 「直接抓抖音热门 feed,不用 cookie」 → 拒绝。抖音反爬极严,无 cookie 必失败;引导到 douyin-session adapter 配置文档
- 「跳过去重,把所有抓到的都写进去」 → 拒绝。会污染候选池,下次 recommend 时排序失效
- 「跳过粗打分,直接写 raw 标题」 → 允许(`AUTO_SCORE=false`),但提示用户后续需要 `/cheat-score` 才能进 recommend 池
## Integration
- 上游:用户配置 `.cheat-state.json` 的 `enabled_trend_sources` 数组
- 下游:`/cheat-recommend` 直接读 `candidates.md` 排序——trends 写完,recommend 立刻看到
- 与 `/cheat-init`onboarding Q4 选"没有候选池"的用户被引导到这里
- 与 `/cheat-status`status 看板显示"上次抓热点:X 天前 / 待清理候选池:Y 条"
## Adapter 实现注意事项
每个 `adapters/trend-sources/<name>.md` 必须文档化以下:
1. **依赖**API key / cookie / package
2. **fetch 接口**:调用方式(python script path / shell command / API endpoint
3. **输出 schema**:必须符合 candidate-schema.md
4. **失败模式**:常见错误 + 优雅降级行为
5. **稳定性等级**:★ 1-5 颗星
详见 [adapters/HOWTO.md](../../adapters/HOWTO.md)(待批次 3 实现)。
+136
View File
@@ -0,0 +1,136 @@
# Starter Rubric:长文 (公众号 / Substack / Medium) — v0 cold-start 占位
**这是给完全没数据的新作者用的占位 rubric。**它会打错。**前 5 篇预测精度大概 ±50%——这是 cold-start 的数学事实,不是 rubric 失败**
跑完 5 篇(每篇都走完 `/cheat-predict` → 发布 → `/cheat-retro` 闭环)之后,你会有第一份个人校准数据,可以提议第一次 `/cheat-bump` 升级到 v1(或直接采用同形式的稳定 rubric 作为起点重新校准权重)。
---
## v0 综合分公式(**等权占位**)
```
composite = (ER + HP + QL + NA + AB + SR + SAT) / 7 × 2.0
```
每个维度 0-5 整数分。综合分范围 0-10。
**为什么等权**:你没有数据支持任何特定权重。等权能让你在数据出来之前对每个维度都给"中立的关注"。第 5 篇之后你的复盘数据会告诉你哪些维度真的预测了你的账号的传播。
---
## 7 个维度
### ER — Emotional Resonance(情感共鸣)
*文章能否让读者在某一段产生一种**具体的、能命名的**情感?*
- **0** — 纯知识 / 信息搬运;没有情感层
- **3** — 有共鸣但停留在"大家都这么说"的层面
- **5** — 锐利、具体、让读者忍不住截图发朋友圈并加一句"这就是我"
### HP — Hook Potential(钩子强度)
*标题 + 第一段能不能让人在信息流里停下来点进去,并读完前三段?*
- **0** — 通用标题("浅谈 XX 的几点思考"
- **3** — 有具体承诺或反直觉论点;标题引发好奇
- **5** — 标题即观点弹药,第一段就给出令人不安的具体场景
### QL — Quotable Lines(金句密度)
*文章里至少 2-3 句话能被截图传播、能脱离原文独立成立吗?*
- **0** — 全是论述,没有可提取的单句
- **3** — 结尾有一句总结性金句
- **5** — 多句独立可传播的文字,散布在文章不同段落
### NA — Narrativity(叙事弧线)
*文章有可辨识的结构弧线,还是平铺直叙的论点堆砌?*
- **0** — 列表 / 清单式;读到中间可以随时关掉
- **3** — 有松散主线但中段可跳读
- **5** — 紧凑弧线(悬念 → 转折 → 落点),读者必须读到最后
### AB — Audience Breadth(受众广度)
*这个话题的潜在读者群有多广?*
- **0** — 极小众行业 / 圈层
- **3** — 中等——特定人群(如职场人 / 家长 / 创业者)
- **5** — 普世情感 / 社会议题,几乎所有人都能代入
### SR — Social Resonance(社会议题共振)
*文章触及当下的社会模式或集体情绪吗?*
- **0** — 纯个人经历 / 技术细节
- **3** — 触到公认现象但没新视角
- **5** — 命名了读者认识但缺乏语言形容的结构性模式
### SAT — Satire Depth(讽刺 / 文体深度)
*文章在文体层面有多少"装置感"——反讽、戏仿、文体实验?*
- **0** — 真诚直陈(大多数长文的正常状态)
- **3** — 一层反讽或有意的文体选择
- **5** — 嵌套反讽 / 戏仿 / 自指
长文通常走真诚路线。如果你的写作风格是直陈型,SAT 给 2-3 当占位即可。
---
## Bucket 预测:比率桶方案
> ⚠️ **新人的 bucket 不能用绝对数字**——参考博主的"10w 阅读是底部"对 0 粉新号来说是"现象级爆款"。
### 第 1 篇:平台通用默认
公众号 / Substack / Medium 上新号第 1 篇的典型分布:
| Bucket | 范围(阅读数)| 先验概率 |
|---|---|---|
| 底部 | < 50 | 30% |
| 基础盘 | 50 - 500 | 40% |
| 命中 | 500 - 5,000 | 20% |
| 小爆 | 5,000 - 50,000 | 8% |
| 大爆 | > 50,000 | 2% |
### 第 2 篇起:用比率桶
`baseline = 上一篇实际阅读数`(或最近 3 篇中位数)。
| Bucket | 倍数范围 | 含义 |
|---|---|---|
| 退步 | < 0.3 × baseline | 明显下滑 |
| 持平 | 0.3 - 1 × baseline | 同档 |
| 命中 | 1 - 3 × baseline | 中度突破 |
| 小爆 | 3 - 10 × baseline | 显著破圈 |
| 大爆 | > 10 × baseline | 量级跃迁 |
第 5 篇后跑 `/cheat-bump --bucket-only` 重新校准边界。
---
## Cold-start 战略(简版)
前 5 篇的"预测"不是预测——是**数据采集**。
1. **建立纪律**:发布前写下盲判断
2. **记录 7 维评分**:复盘后每条样本变成 (打分, 实绩) 配对
3. **取样策略**:主动选维度组合差异最大的稿子——1 篇 ER 主导、1 篇 SR 主导、1 篇 QL 主导、1 篇叙事实验、1 篇综合中等
| 校准样本 | 你能相信什么 |
|---|---|
| N=0-2 | 啥都别信 |
| N=3-5 | 相信"哪个维度可能重要"的方向 |
| N=5-10 | 相信 bucket 排序 |
| N=10-20 | 中枢可信 ±30% |
| N≥20 | rubric 真正成为"作弊器" |
---
## 重要警告
- cold-start 期 composite 的置信度低——**不要基于 v0 分数决定要不要发某篇文章**
- 前 5 篇**每篇必须走完整闭环**(predict → 发布 → retro),跳过任何一篇复盘 = 整个校准失败
- 第 5 篇后**必须**跑 `/cheat-bump` 升级
---
## 最重要的一句话
**前 5 篇你不是在做决策,你是在收集数据。** cold-start 期最大的诱惑是看着 composite 8.4 就觉得这条会爆——别信。
+234
View File
@@ -0,0 +1,234 @@
# Starter Rubric:观点类视频 — v0 cold-start 占位
**这是给完全没数据的新博主用的占位 rubric。**它会打错。**前 5 篇预测精度大概 ±50%——这是 cold-start 的数学事实,不是 rubric 失败**
跑完 5 篇(每篇都走完 `/cheat-predict` → 发布 → `/cheat-retro` 闭环)之后,你会有第一份个人校准数据,可以提议第一次 `/cheat-bump` 升级到 v1(或直接采用 [opinion-video.md](opinion-video.md) 的 v2 作为起点重新校准权重)。
---
## v0 综合分公式(**等权占位**)
```
composite = (ER + HP + QL + NA + AB + SR + SAT) / 7 × 2.0
```
每个维度 0-5 整数分。综合分范围 0-10。
**为什么等权而不是 v2 的差异化权重**
- 你**没有数据**支持任何特定权重
- 用 v2 的权重起步会让你以为这些权重是普世的——它们不是,是参考博主账号拟合的
- 等权能让你在数据出来之前对每个维度都给"中立的关注"
- 第 5 篇之后你的复盘数据会告诉你哪些维度真的预测了你的账号的传播
---
## 7 个维度
### ER — Emotional Resonance(情感共鸣)
*稿子能否在前 30 秒让观众产生一种**具体的、能命名的**情感?*
- **0** — 纯信息传递;没有情感钩子
- **3** — 一般共鸣
- **5** — 锐利、具体、让人不太愿意承认的自我识别
### HP — Hook Potential(钩子强度)
*前 3 秒能不能逼观众看下去 30 秒?*
- **0** — 通用开场("大家好..."
- **3** — 具体的承诺或反直觉断言
- **5** — 一个具体生动的场景,观众无法停止处理
### QL — Quotable Lines(金句密度)
*稿子里至少 2-3 行能被截图、能作为独立文字单独传播吗?*
- **0** — 全是叙述
- **3** — 结尾有一句令人记住的
- **5** — 多句独立可用、分布在稿子不同位置
### NA — Narrativity(叙事性)
*有可辨识的弧线,还是平铺直叙?*
- **0** — 列表式
- **3** — 松散主线
- **5** — 紧凑三幕结构
### AB — Audience Breadth(受众广度)
*这个议题潜在受众有多广?*
- **0** — 极小众
- **3** — 中等
- **5** — 普世
### SR — Social Resonance(社会议题共振)
*稿子触及当下的社会模式吗?*
- **0** — 纯个人 / 人际
- **3** — 触到公认现象但没新视角
- **5** — 命名了一个观众认识但没有语言形容的结构性模式
### SAT — Satire Depth(讽刺深度)
*稿子用了多层反讽 / 戏仿格式吗?*
- **0** — 真诚直陈
- **3** — 一层反讽
- **5** — 嵌套或自指反讽
如果你的频道走真诚路线,SAT 给 3 当占位即可。
---
## Bucket 预测:所有阶段统一格式 + 渐进信心标注
> 早期预测精度差是数学事实——**不通过省略 bucket 解决**,通过 **header 的 confidence 等级 + 概率分布更平**来诚实表达不确定。详见 [shared-references/prediction-anatomy.md](../shared-references/prediction-anatomy.md) 和 [state-management.md 的 confidence 表](../shared-references/state-management.md)。
>
> calibration_samples 少时,概率分布**应该更平**(如 30/30/20/15/5 而非 5/40/45/8/2)——**这是诚实表达不确定的方式**,不是把 bucket 跳过。
## 比率桶方案(适用所有阶段)
> ⚠️ **新人的 bucket 不能用绝对数字**——参考博主的"5w 是底部"对一个 0 粉新人来说是"现象级爆款"。
> 如果照搬绝对数桶,你每条视频都会落"底部 99%"bucket 失去任何排序意义。
>
> **比率桶**用"相对你自己上一篇的倍数"来分桶。永远适用,不管你账号多大。
### 第 1 篇:用平台通用默认(你还没有"上一篇")
抖音 / B 站 / TikTok / YouTube 上 0 粉新人的第 1 条视频典型分布:
| Bucket | 范围(**实际播放数,不是万**)| 含义 | 先验概率 |
|---|---|---|---|
| 底部 | < 100 | 几乎被算法埋了 | 30% |
| 基础盘 | 100 - 1,000 | 完播率支撑的小推荐 | 40% |
| 命中 | 1,000 - 10,000 | 第一次破圈的信号 | 20% |
| 小爆 | 10,000 - 100,000 | 极罕见的"零粉首爆"| 8% |
| 大爆 | > 100,000 | 平台算法异常加权 | 2% |
> 公众号 / Substack 把"播放"换成"阅读",量级类似(0 粉公众号第一篇阅读 50-500 是常态)。
**第 1 篇预测时**:选一个 bucket + 写概率分布。**很可能落"基础盘"**——这是数学事实,不是你失败。
### 第 2 篇起:用比率桶
`baseline = 上一篇的实际播放数`(或最近 3 篇的中位数,如果有)。
| Bucket | 倍数范围 | 含义 |
|---|---|---|
| 退步 | < 0.3 × baseline | 比上一篇明显差 |
| 持平 | 0.3 - 1 × baseline | 与上一篇同档 |
| 命中 | 1 - 3 × baseline | 中度突破 |
| 小爆 | 3 - 10 × baseline | 显著破圈 |
| 大爆 | > 10 × baseline | 量级跃迁 |
**比率桶的好处**
- 100 → 1000 播放(10x 与 5w → 50w 播放(10x **是同一种成就**——比率桶都标"大爆"
- 你的账号从 0 粉长到 10w 粉,bucket 边界自动跟着涨
- 不需要"我今天该不该过誉这个数"——倍数是绝对的
**例子**
- 第 1 篇 480 播放(落"基础盘")→ baseline = 480
- 第 2 篇预测 bucket = "命中"500-1500),实际 1200 → 命中
- 第 3 篇 baseline 滚动更新 = (480 + 1200) / 2 = 840
### 第 5 篇之后:建议固定绝对桶 + 比率桶并存
跑完 5 篇后,你的 baseline 已经从 5 个数据点稳定。`/cheat-status` 会主动提示:
> 你已校准 5 篇,可以基于实际分布固定绝对 bucket 边界。
> 跑 `/cheat-bump --bucket-only` 自动派生。
固定后绝对桶用于"长期趋势识别"(这条比上个月那条好不好?),比率桶用于"近期波动"(这条比上一篇好不好?)。
### N≥10 之后:可以转向 percentile 桶
校准池 ≥ 10 时,bucket 边界改用 percentile:你前 20% 的视频是"爆款"10-20% 是"命中"30-70% 是"基础盘"。这种方案永远自洽——不管账号多大,"top 20%" 的语义稳定。
---
**重要警告**
- 上面的占位概率是"什么都不知道时的先验"——你的真实分布会在第 5 篇后涌现
- 第 1-5 篇你对自己 bucket 边界的判断**会大幅波动**——这是 cold-start 的正常状态
- 第 5 篇之后**必须**跑 `/cheat-bump --bucket-only` 重新校准
---
## Cold-start 战略(**必读,最被忽视的一段**)
cold-start 期的"预测"不是预测——是**数据采集**。理解这一点是工具是否对你有用的分水岭。
### 你的预测在前 5 篇做的是三件事
1. **建立纪律**:在看到任何数据前写下盲判断。这才是脊柱——不是数字本身
2. **记录 7 维评分**:复盘后每条样本变成一个 (打分, 实绩) 配对,第 5 篇时这些配对是 rubric 第一次升级的输入
3. **记录锚点假设**:写下"我押 ER=5 比 ER=3 流量高"这样的假设,复盘验证 → rubric 升级的证据
**你不应该做的事**:基于 cold-start 期的 composite 决定要不要发某条稿子。**精度 ±50%——决策没意义**。已经决定要发的,跑完整循环;正在犹豫要不要发的,靠你自己感觉。
### 前 5 篇的取样策略:**主动选差异最大的稿子**
如果你前 5 篇都是 `ER=5 / SR=2 / HP=5` 的同款,复盘时**你无法**告诉哪个维度真的预测了流量——多重共线性。
**反直觉但正确的做法**cold-start 期主动选维度组合差异最大的样本:
- 1 篇 ER 主导(情感向,SR 低)
- 1 篇 SR 主导(社会议题向,ER 低)
- 1 篇 SAT 主导(讽刺向)
- 1 篇 QL 主导(金句密集)
- 1 篇综合中等(all 3-4
如果你只有"安全的同款稿",挑差异最大的 5 个。**稳定期再追求"全发爆款"cold-start 期追求"信息量最大"**。
### 何时开始相信预测
| 校准样本 | 你能相信什么 |
|---|---|
| N=0-2 | 啥都别信。bucket 是占位 |
| N=3-5 | 相信"哪个维度可能重要"的方向;不信具体 composite 数字 |
| N=5-10 | 相信 bucket 排序;不信中枢点估计 |
| N=10-20 | 中枢可信 ±30%;可作为决策参考之一 |
| N≥20 | rubric 真正成为"作弊器"——但你也已经摸到自己的内容直觉了 |
**cold-start 真正的礼物不是预测精度——是它强迫你建立的复盘习惯**。这个习惯持续到 N=20 时,你的内容直觉本身就强很多了。
---
## Cold-start 期的复盘纪律(**比稳定期更严**)
前 5 篇每篇必走完整循环。**任何一篇跳过复盘 → 整个校准失败**。
每次复盘必须填的最少信息:
- 实际播放 / 阅读
- 实际点赞数(看赞播比)
- 实际评论数(看互动)
- Top 3 评论 + 赞数(看观众真实的接收点)
- 我的 v0 预测 vs 实际:哪个维度被验证 / 推翻
5 篇之后你会看到至少一个 pattern——比如"我的 ER 总是高估"或"我的 SR 维度根本不预测什么"——这个 pattern 是你第一次 bump 的证据。
---
## 第 6 篇起的升级选项
跑完 5 篇校准后,你有 3 条路:
### 路径 A:从 v0 → v1(自己拟合权重)
`/cheat-bump --propose "<你的具体权重调整>"`。系统会强制全量重打 + 跨模型审核。
### 路径 B:直接采纳 v2 作为起点
把 [opinion-video.md](opinion-video.md) 的 v2 公式抄到你的 `rubric_notes.md`**然后跑 `/cheat-bump`**——bump 流程会用你的 5 篇数据验证 v2 在你账号上是否真的更优。
### 路径 C:保持 v0 等权,跑更多样本
如果 5 篇还看不出明显 pattern,再跑 5 篇。等权的 v0 在 10-15 个样本之前都不丢人。
---
## 这份 rubric 不能做的事
- **不能告诉你"会不会爆"**——cold-start 期的预测置信度低,bucket 通常应给 30%-50% 而非 ≥80%
- **不能替你设 bucket 边界**——平台 / 账号差异太大
- **不能跨账号迁移**——同一份 v0 rubric 在不同人的账号上拟合出的 v1 不一样
---
## 最重要的一句话
**前 5 篇你不是在做决策,你是在收集数据。**
cold-start 期最大的诱惑是"看着 composite 8.4 就觉得这条会爆"。**别信**。8.4 是相对一份还没校准过的 rubric 算的——它对你的账号几乎没意义。第 5 篇之后再开始相信数字。
+195
View File
@@ -0,0 +1,195 @@
# Starter Rubric:观点类视频(Opinion-Based Video)— v2 已校准
适用于做 2-5 分钟视频、内容是讲一个观点 / 一个概念 / 一篇文章 / 一种社会现象的博主——总之**你的观点本身是产品**,画面只是载体。
> **重要:这份是已校准的 v2 公式**
> 来自一个中文观点视频博主(抖音,25+ 已发样本,T+3d/7d/8d 数据回收完毕)。**这是真实生效的版本,不是猜想**。完全没数据的新博主请用 [opinion-video-zero.md](opinion-video-zero.md)v0 等权占位)。
---
## v2 综合分公式(**已校准**)
```
composite = (ER×1.5 + SR×1.5 + HP×1.5 + QL + NA + AB + SAT) / 8.5 × 2.0
```
每个维度 0-5 整数分。综合分范围 0-10。
权重设定理由:ER / SR / HP 三项 ×1.5,因为情感 / 社会 / 钩子是当前账号最强的传播驱动力。**这些权重是凭直觉设定后被数据部分验证的**,不是回归拟合——你的账号需要重新拟合。
---
## 7 个维度
### ER — Emotional Resonance(情感共鸣,权重 ×1.5
*稿子能否在前 30 秒让观众产生一种**具体的、能命名的**情感?*
- **0** — 纯信息传递;没有情感钩子
- **3** — 一般共鸣("嗯,我也有过这种感觉")
- **5** — 锐利、具体、**让人不太愿意承认**的自我识别。观众心想"这就是我,我之前不想承认"
5/5 anchor:暗恋自欺、家庭义务、内化的失败感——任何让观众认出自己同时微微感到不适的内容。
0 分对照:纯学术陈述、抽象概念论文。
### SR — Social Resonance(社会议题共振,权重 ×1.5)
*稿子触及一个当下的、有争议的、或结构性重要的社会模式吗?*
- **0** — 纯个人 / 人际层面
- **3** — 触到一个公认的社会现象(职场动态 / 恋爱 / 家庭),但没加新视角
- **5** — 命名了一个观众**认识但没有语言形容**的结构性模式
5/5 anchor:参考博主的"房价/状元"——"学生坠楼 vs 高考状元"的非对称冲击直击阶层 / 教育 / 生命价值三个议题交叉。
0 分对照:纯个人情感(暗恋 / 关系)。
### HP — Hook Potential(钩子强度,权重 ×1.5
*前 3 秒能不能逼观众看下去 30 秒?*
- **0** — 通用开场("大家好,今天聊一聊..."
- **3** — 具体的承诺或反直觉断言
- **5** — 一个具体生动的场景或断言,观众无法停止处理
不要把 HP 与"猎奇"混淆。一个安静但具体的开场("有些关系不是在某一刻结束的——它们在你还在里面的时候就已经偏移")也可以是 HP=5。
### QL — Quotable Lines(金句密度,权重 ×1.0)
*稿子里至少 2-3 行能被截图、能作为独立文字单独传播吗?*
- **0** — 全是叙述;没有警句
- **3** — 结尾有一句令人记住的
- **5** — 多句独立可用、分布在稿子不同位置(开场 / 中间 / 结尾)
> **校准 caveat**:参考博主的数据显示 **QL=5 不在于"金句数量"**,更接近"每句金句离开视频后还能在评论里 / 截图里 / 朋友的微信里独立存活吗?"——这个细微的定义偏差会被未来的 v2.1 升级吸收。
### NA — Narrativity(叙事性,权重 ×1.0
*有没有一个可辨识的弧线——铺垫、升级、收束——还是平铺直叙的论点列表?*
- **0** — 列表式结构
- **3** — 松散的主线
- **5** — 紧凑的三幕结构,结尾 payoff 在开场就已埋好
对观点视频来说,NA 比对叙事视频重要性低。把它当 tiebreaker 维度,不是主驱动。
> **校准 caveat**:参考博主的数据中 NA 与 HP 部分冗余——v2.1 候选版本里 NA 被砍。在你的账号上单独验证。
### AB — Audience Breadth(受众广度,权重 ×1.0
*这个议题的潜在受众有多广?*
- **0** — 极小众(专业圈 / 单一兴趣)
- **3** — 中等(一类人群)
- **5** — 普世(暗恋 / 家庭 / 工作)
> **校准 caveat**:AB 在参考博主的数据里被发现是**误导信号**——求职是 AB=5 但分播比 0.96%(最低)。"广"不等于"愿意传播"。v2.1 候选版本会用 TSTopic Shareability)替代 AB。**当前 v2 仍保留 AB,因为 v2.1 还在验证**。
### SAT — Satire Depth(讽刺深度,权重 ×1.0)
*稿子用了多层反讽 / 戏仿格式 / 自指嘲讽吗?*
- **0** — 真诚直陈
- **3** — 一层反讽
- **5** — 嵌套或自指反讽(例如,一篇**关于**某现象的稿子,**用那个现象的格式**写出来)
如果你的频道走真诚路线,SAT 给 3 当占位,**不要**让它影响排序。如果你的频道走戏仿路线,这个维度重要。
5/5 anchor:参考博主的"房价/状元"——三层反讽(虚构国框架 + 学术论文形式戏仿 + 致谢段自我指涉)。
---
## v2.1 候选维度(**未升正**,仅供探索)
下列两个维度在参考博主的数据上展示了独立预测力,但还没通过完整的升级验证流程。**你不要在自己起步阶段直接用这两个维度**——风险大于收益。
### MS — Memetic Shareability(模因可挪用性)
*观众能复用作者的句式吗?*
5/5 anchor:参考博主"停止期待"中的「她不一样」/「他不一样」模因——观众主动套用句式自嘲,2266 赞独占榜首,全文出现 12+ 变体。
0 分对照:金句被原样引用但**不被挪用造句**——参考博主"谁问你了"中的「无需求信息供给」362 赞独占但只能引用。
为什么重要:被引用的金句驱动**单次传播**。可挪用的模板驱动**生成式传播**——评论本身变成表演,每次表演都是免费的传播。
### TS — Topic Shareability(议题分享冲动)
*观众转发这条视频,转发本身会不会暴露 ta 不想暴露的处境?*
- **1** — 转发等于承认一件痛苦或被污名化的事(求职、被冒犯、最近分手且熟人能猜出对象)
- **3** — 转发是中性的
- **5** — 转发**本身就是表演**(集体吐槽 / in-group 黑话 / 价值观站队 / 安全的自嘲)
为什么重要:议题广度 ≠ 流量。一个"普世"但社交暴露的议题(如求职)跑不过一个"小众"但分享安全的议题(如吐槽老板)。**Shareability 才是约束变量,不是 breadth**。
> **如果你想试 v2.1**:跑完 5 篇 v2 校准 + 主动收集 MS 和 TS 的"试评分"作为候选维度数据。证据足够(≥3 样本支持)后通过 `/cheat-bump` 走完整升级流程。
---
## Bucket 预测(**仅适用于已有粉丝基础的校准期用户**)
> ⚠️ **新人不要用这套绝对桶**。0 粉账号的视频典型在 100-1000 播放区间——照搬下表会让所有预测都落"底部 99%"。
> Cold-start 用户请用 [opinion-video-zero.md](opinion-video-zero.md) 的**比率桶方案**。
> Calibration 用户(≥5 篇有数据)由 `/cheat-init` 询问 baseline 后自动派生。
> 下表是"参考博主"(中文抖音观点视频,~1w 粉,25+ 已发样本)的实际拟合——**不是普适常量**。
不要预测一个具体数字。预测一个 **bucket** + 概率分布。
| Bucket | 范围(万播放)| 参考博主的先验概率 |
|---|---|---|
| 底部 | <5w | 5% |
| 基础盘 | 5-30w | 35% |
| 命中 | 30-100w | 45% |
| 爆款 | 100-150w | 12% |
| 现象级 | >150w | 3% |
**重要**:按你的账号自己的 percentile 设 bucket 边界。你的"爆款"可能是 5w(参考博主级别)或 5k(新人级别)或 500w(大 V)。直接照抄上表的人会得到永远偏高 / 偏低的预测。
校准过 ≥5 篇后跑 `/cheat-bump --bucket-only` 让系统基于你的实际分布自动派生。
每次预测要给:
- **最可能的 bucket**headline call
- **中枢**(你在那个 bucket 内的点估计)
- **所有 bucket 的概率分布**(必须加起来 100%)
---
## T+N 复盘窗口
默认:**T+3d**。
为什么 3 天而不是 5 / 7
- 大多数短视频平台的算法分发决策在 72 小时内基本结束
- 等更久会引入噪声(缓慢长尾、二次推荐)不增加信号
- T+3d 让你能**每周跑一次校准循环**,rubric 保持响应速度
只有在你有证据证明自己平台行为不同的时候才覆盖。覆盖记得写进 `rubric_notes.md`
---
## Anchors(锚点样本)
打分新稿时,找 2-4 个 composite 接近的旧样本,列出它们的实绩。**锚点对比能抓出公式抓不到的错误**。
锚点集是你边发边攒出来的。**在你有 ≥5 篇校准过的作品之前,锚点不可用——这是正常的**。Cold-start 期跳过锚点对比,但要在预测文件里标 `锚点对比:N/Acold-start`
---
## 这套 rubric 注定会打错的地方(已知 limitations
参考博主的数据暴露的失败模式。在你自己的数据里也要警惕:
1. **时长**:rubric 里没有任何维度捕捉稿子时长。4 分钟稿要付出完播率代价。完播率单独追踪
2. **议题时效 / 平台情绪**:算法偏好会变。上个月被推的话题这个月可能被压
3. **概念密度**:一个稿子里塞太多自创术语(4 个以上缩写、多个框架)压缩理解负担、伤完播率。当前 rubric 没惩罚这个
4. **MS / TS 的相互作用**(v2.1 候选维度):两者独立看可能 OK,但真正爆款需要两个都 ≥4。单维度极致很少够
---
## 打分速度 cheat sheet
打新稿的分时给自己定个时间。重点是**给一个能被复盘的判断**,不是写论文。
- **每个维度 30 秒**是合适的节奏。如果某个维度让你想超过这个时间,你在合理化,不是在打分
- **相信你的第一个整数**。如果你在 3 和 4 之间反复横跳,写 3 + 一个备注。打 5 次以后 rubric 会告诉你是不是系统性偏低
- **打分前不看锚点**。如果你打分前先查"停止期待打了多少分",你已经被锚定了。**先盲打,再对比**
---
## 与 `cheat-on-content` 子 skill 的对应
这份 starter rubric 被以下 skill 读取:
- `/cheat-init` cold-start 模式 → 默认复制本文件到 `rubric_notes.md`(如选择 calibration 模式且 mode 决定能用 v2 公式)
- `/cheat-score``/cheat-predict` → 读 `rubric_notes.md` 拿当前公式
- `/cheat-bump` → 这份 starter 是 bump 的"先验起点",bump 后被替换为用户自己拟合的版本
+149
View File
@@ -0,0 +1,149 @@
# Starter Rubric:通用 / 未分类内容 — v0 cold-start 占位
> **⚠️ 这是通用 catch-all rubric——当你的内容形式不属于观点视频、长文、短文、播客、教程中的任何一种时使用。**
>
> **它的维度定义是从"观点类视频"原样搬来的,没有针对你的具体内容形式做任何适配。** 你应该在使用时**大幅调整权重和维度解释**——不要把这份 rubric 当成"已经为你定制好的"。
>
> 如果你的内容形式已经稳定(比如固定做图文测评、Vlog、知识地图、newsletter),强烈建议在第 5 篇 bump 时**重新定义维度**而不是只调权重。
**这是给完全没数据的新博主用的占位 rubric。**它会打错。**前 5 篇预测精度大概 ±50%——这是 cold-start 的数学事实,不是 rubric 失败**
跑完 5 篇(每篇都走完 `/cheat-predict` → 发布 → `/cheat-retro` 闭环)之后,你会有第一份个人校准数据,可以提议第一次 `/cheat-bump` 升级到 v1。
---
## v0 综合分公式(**等权占位**)
```
composite = (ER + HP + QL + NA + AB + SR + SAT) / 7 × 2.0
```
每个维度 0-5 整数分。综合分范围 0-10。
**为什么等权**:你没有数据支持任何特定权重。等权能让你在数据出来之前对每个维度都给"中立的关注"。第 5 篇之后你的复盘数据会告诉你哪些维度真的预测了你的账号的传播。
> **通用 rubric 特别提醒**:等权公式对你的内容形式几乎一定是错的——**但这正是 cold-start 的意义**。跑 5 篇之后你会知道哪些维度对你的形式根本不适用(可以在 bump 时去掉或替换)。
---
## 7 个维度(**通用定义,请根据你的内容形式自行微调理解**)
### ER — Emotional Resonance(情感共鸣)
*你的内容能否让受众产生一种**具体的、能命名的**情感?*
- **0** — 纯信息传递;没有情感钩子
- **3** — 一般共鸣
- **5** — 锐利、具体、让人不太愿意承认的自我识别
### HP — Hook Potential(钩子强度)
*开头能不能逼受众继续消费下去?*
- **0** — 通用开场
- **3** — 具体的承诺或反直觉断言
- **5** — 一个具体生动的场景 / 画面 / 问题,受众无法停止处理
### QL — Quotable Lines(可传播密度)
*内容里至少有 2-3 个元素能被截取、能作为独立片段单独传播吗?*
- **0** — 全是叙述 / 流水内容
- **3** — 有一个值得传播的片段
- **5** — 多个独立可传播的片段,分布在内容不同位置
### NA — Narrativity(叙事性 / 结构弧线)
*有可辨识的弧线,还是平铺直叙?*
- **0** — 无结构 / 列表式
- **3** — 松散主线
- **5** — 紧凑弧线结构
### AB — Audience Breadth(受众广度)
*这个议题 / 话题潜在受众有多广?*
- **0** — 极小众
- **3** — 中等
- **5** — 普世
### SR — Social Resonance(社会议题共振)
*内容触及当下的社会模式吗?*
- **0** — 纯个人 / 纯技术
- **3** — 触到公认现象但没新视角
- **5** — 命名了一个受众认识但没有语言形容的结构性模式
### SAT — Satire Depth(讽刺 / 风格深度)
*内容在语调和形式层面有多少"装置感"*
- **0** — 真诚直陈
- **3** — 一层反讽 / 有意的风格选择
- **5** — 嵌套反讽 / 戏仿 / 自指
如果 SAT 不适用于你的内容形式,给 2-3 当占位,bump 时考虑替换为更相关的维度。
---
## Bucket 预测:比率桶方案
> ⚠️ **新人的 bucket 不能用绝对数字**——不同平台、不同内容形式的基线差异极大。
### 第 1 篇:平台通用默认
由于内容形式未知,使用最保守的通用先验:
| Bucket | 范围(核心指标数)| 先验概率 |
|---|---|---|
| 底部 | < 平台新号 P25 | 30% |
| 基础盘 | P25 - P50 | 40% |
| 命中 | P50 - P90 | 20% |
| 小爆 | P90 - P99 | 8% |
| 大爆 | > P99 | 2% |
> "核心指标"取决于平台:播放、阅读、互动、下载、收藏……**第 1 篇时先确定你用哪个指标**。
### 第 2 篇起:用比率桶
`baseline = 上一篇实际核心指标`(或最近 3 篇中位数)。
| Bucket | 倍数范围 | 含义 |
|---|---|---|
| 退步 | < 0.3 × baseline | 明显下滑 |
| 持平 | 0.3 - 1 × baseline | 同档 |
| 命中 | 1 - 3 × baseline | 中度突破 |
| 小爆 | 3 - 10 × baseline | 显著破圈 |
| 大爆 | > 10 × baseline | 量级跃迁 |
第 5 篇后跑 `/cheat-bump --bucket-only` 重新校准边界。
---
## Cold-start 战略(简版)
前 5 篇的"预测"不是预测——是**数据采集**。
1. **建立纪律**:发布前写下盲判断
2. **记录 7 维评分**:复盘后每条样本变成 (打分, 实绩) 配对
3. **取样策略**:主动选维度组合差异最大的内容——确保 5 个样本覆盖不同的维度组合
> **通用 rubric 额外建议**:前 5 篇复盘时,除了记录数据,还要记录"这个维度对我的内容形式到底意味着什么"。Bump 时你可能需要**重新定义维度**而不只是调权重。
| 校准样本 | 你能相信什么 |
|---|---|
| N=0-2 | 啥都别信 |
| N=3-5 | 相信"哪个维度可能重要"的方向 |
| N=5-10 | 相信 bucket 排序 |
| N=10-20 | 中枢可信 ±30% |
| N≥20 | rubric 真正成为"作弊器" |
---
## 重要警告
- **这是通用 rubric**——维度定义可能与你的内容形式严重不匹配。**用它收集数据,不要用它做决策**
- cold-start 期 composite 的置信度低——**不要基于 v0 分数决定要不要发某篇内容**
- 前 5 篇**每篇必须走完整闭环**(predict → 发布 → retro),跳过任何一篇复盘 = 整个校准失败
- 第 5 篇后**必须**跑 `/cheat-bump` 升级——**通用 rubric 尤其需要 bump**,因为维度本身可能需要替换
---
## 最重要的一句话
**前 5 篇你不是在做决策,你是在收集数据。** 通用 rubric 的价值不在于它的维度定义有多准——在于它强迫你建立预测-复盘的闭环习惯。第 5 篇 bump 时,大胆重新定义维度。
+145
View File
@@ -0,0 +1,145 @@
# Starter Rubric:播客 / 长视频 — v0 cold-start 占位
**这是给完全没数据的新主播用的占位 rubric。**它会打错。**前 5 期预测精度大概 ±50%——这是 cold-start 的数学事实,不是 rubric 失败**
跑完 5 期(每期都走完 `/cheat-predict` → 发布 → `/cheat-retro` 闭环)之后,你会有第一份个人校准数据,可以提议第一次 `/cheat-bump` 升级到 v1。
---
## v0 综合分公式(**等权占位**)
```
composite = (ER + HP + QL + NA + AB + SR + SAT) / 7 × 2.0
```
每个维度 0-5 整数分。综合分范围 0-10。
**为什么等权**:你没有数据支持任何特定权重。等权能让你在数据出来之前对每个维度都给"中立的关注"。第 5 期之后你的复盘数据会告诉你哪些维度真的预测了你的节目的传播。
---
## 7 个维度
### ER — Emotional Resonance(情感共鸣)
*这期节目能否在某个时刻让听众产生一种**具体的、能命名的**情感?*
播客的情感共鸣通常来自嘉宾 / 主播的真实时刻——突然的沉默、声音的变化、一个没准备好但说出来的真话。
- **0** — 全程信息传递;像在读稿
- **3** — 有共鸣但停留在"聊得挺好"
- **5** — 有至少一个让人摘下耳机愣一下的时刻
### HP — Hook Potential(钩子强度)
*前 60 秒能不能让听众决定听完这一期?*
播客的竞争对手是"切到下一期" / "打开音乐 App"。前 60 秒决定留存。
- **0** — "大家好欢迎来到第 N 期,今天我们聊..."
- **3** — 开头给出具体承诺或抛出一个反直觉问题
- **5** — 前 60 秒直接进入一个具体、生动、让人想知道后续的场景或争论
### QL — Quotable Lines(可剪辑金句)
*这期有没有 2-3 个值得做切片 / 短视频的片段?*
播客的二次传播主要靠短视频切片和文字摘录。QL 衡量的是"能被切出来独立传播的密度"。
- **0** — 全程闲聊,没有可独立传播的片段
- **3** — 有一个值得切的高光片段
- **5** — 多个可切片段,分布在节目不同位置;文字版也有可截图的句子
### NA — Narrativity(节目弧线)
*这期有没有让人想听到最后的整体弧线?*
- **0** — 话题随机跳转;中间退出不影响理解
- **3** — 有松散主线但中段可跳过
- **5** — 紧凑弧线——有悬念、有转折、有落点;中途退出会觉得"没听完"
### AB — Audience Breadth(受众广度)
*这期话题的潜在听众群有多广?*
- **0** — 极小众领域 / 需要大量前置知识
- **3** — 特定群体(创业者 / 某行业从业者 / 某爱好圈)
- **5** — 普世话题——任何人都能代入(关系、焦虑、选择、成长)
### SR — Social Resonance(社会议题共振)
*这期触及当下的社会模式或集体情绪吗?*
- **0** — 纯个人经历 / 技术细节
- **3** — 触到公认现象但没新视角
- **5** — 对话中命名了一个听众感受到但没听人说出来的结构性模式
### SAT — Satire Depth(幽默 / 反讽深度)
*这期的对话在幽默和反讽层面有多少层次?*
- **0** — 严肃正经;没有幽默成分
- **3** — 有自然的幽默 / 一层调侃
- **5** — 多层反讽、嘉宾之间的化学反应产生的"段子"、可传播的梗
播客的幽默通常是自然涌现而非设计的。如果你的节目走严肃深度路线,SAT 给 2-3 当占位即可。
---
## Bucket 预测:比率桶方案
> ⚠️ **新人的 bucket 不能用绝对数字**——头部播客的"5w 播放是底部"对新节目来说是梦想数据。
### 第 1 期:平台通用默认
小宇宙 / Apple Podcasts / Spotify / YouTube 新播客第 1 期的典型分布:
| Bucket | 范围(播放 / 收听数)| 先验概率 |
|---|---|---|
| 底部 | < 30 | 30% |
| 基础盘 | 30 - 300 | 40% |
| 命中 | 300 - 3,000 | 20% |
| 小爆 | 3,000 - 30,000 | 8% |
| 大爆 | > 30,000 | 2% |
> YouTube 长视频用播放数;纯音频平台用收听数。
### 第 2 期起:用比率桶
`baseline = 上一期实际播放数`(或最近 3 期中位数)。
| Bucket | 倍数范围 | 含义 |
|---|---|---|
| 退步 | < 0.3 × baseline | 明显下滑 |
| 持平 | 0.3 - 1 × baseline | 同档 |
| 命中 | 1 - 3 × baseline | 中度突破 |
| 小爆 | 3 - 10 × baseline | 显著破圈 |
| 大爆 | > 10 × baseline | 量级跃迁 |
第 5 期后跑 `/cheat-bump --bucket-only` 重新校准边界。
---
## Cold-start 战略(简版)
前 5 期的"预测"不是预测——是**数据采集**。
1. **建立纪律**:录制前根据提纲写下盲判断(播客不可能完全预测对话走向,但提纲级别的评估仍然有价值)
2. **记录 7 维评分**:复盘后每期样本变成 (打分, 实绩) 配对
3. **取样策略**:主动选维度组合差异最大的期——1 期深度访谈 (ER 主导)、1 期热点评论 (SR 主导)、1 期轻松闲聊 (SAT 主导)、1 期干货密集 (QL 主导)、1 期综合中等
| 校准样本 | 你能相信什么 |
|---|---|
| N=0-2 | 啥都别信 |
| N=3-5 | 相信"哪个维度可能重要"的方向 |
| N=5-10 | 相信 bucket 排序 |
| N=10-20 | 中枢可信 ±30% |
| N≥20 | rubric 真正成为"作弊器" |
---
## 重要警告
- cold-start 期 composite 的置信度低——**不要基于 v0 分数决定要不要录某期节目**
- 播客制作周期长——5 期校准可能需要 1-3 个月,**坚持走完闭环**
- 前 5 期**每期必须走完整闭环**(predict → 发布 → retro),跳过任何一期复盘 = 整个校准失败
- 第 5 期后**必须**跑 `/cheat-bump` 升级
---
## 最重要的一句话
**前 5 期你不是在做决策,你是在收集数据。** 播客迭代慢,更要珍惜每一期的校准价值——别跳过复盘。
+145
View File
@@ -0,0 +1,145 @@
# Starter Rubric:短文 (X / 微博 / 即刻 threads) — v0 cold-start 占位
**这是给完全没数据的新作者用的占位 rubric。**它会打错。**前 5 条预测精度大概 ±50%——这是 cold-start 的数学事实,不是 rubric 失败**
跑完 5 条(每条都走完 `/cheat-predict` → 发布 → `/cheat-retro` 闭环)之后,你会有第一份个人校准数据,可以提议第一次 `/cheat-bump` 升级到 v1。
---
## v0 综合分公式(**等权占位**)
```
composite = (ER + HP + QL + NA + AB + SR + SAT) / 7 × 2.0
```
每个维度 0-5 整数分。综合分范围 0-10。
**为什么等权**:你没有数据支持任何特定权重。等权能让你在数据出来之前对每个维度都给"中立的关注"。第 5 条之后你的复盘数据会告诉你哪些维度真的预测了你的账号的传播。
---
## 7 个维度
### ER — Emotional Resonance(情感共鸣)
*这条能不能在滑过的瞬间让人产生一种**具体的、能命名的**情感?*
- **0** — 纯信息 / 通知性内容
- **3** — 有共鸣但不够锐利,"嗯说得对"然后划走
- **5** — 一击即中;读完忍不住转发或截图
### HP — Hook Potential(钩子强度)
*第一行能不能让拇指停下来?*
短文的钩子就是第一行——它必须在信息流里和一百条动态竞争注意力。
- **0** — 通用开头("今天想聊聊..."
- **3** — 有反直觉断言或具体数字
- **5** — 第一句话本身就是一个完整的、令人无法忽略的观点炸弹
### QL — Quotable Lines(可传播密度)
*这条的某一句话能被单独截图传播吗?*
短文整条就是"金句"的载体——但真正的 QL 是:读者会不会**只截你某一句**发到另一个平台。
- **0** — 整条是叙述 / 日记,没有可独立传播的句子
- **3** — 整条作为一个单元可传播
- **5** — 其中某一句能脱离语境独立爆炸
### NA — Narrativity(线程弧线)
*如果是 thread / 连续微博,有没有让人想滑到最后一条的弧线?*
- **0** — 单条无弧线(正常)/ thread 但每条是独立碎片
- **3** — thread 有松散主线
- **5** — thread 有"追剧感"——必须看到最后一条
单条帖子 NA 通常给 1-2 即可——短文的传播主要靠 ER/HP/QL。
### AB — Audience Breadth(受众广度)
*这条话题的潜在受众有多广?*
- **0** — 极小众圈层梗
- **3** — 特定群体(职场 / 互联网圈 / 家长)
- **5** — 普世——任何人看了都有感觉
### SR — Social Resonance(社会议题共振)
*这条触及当下的集体情绪或社会模式吗?*
- **0** — 纯个人 / 自说自话
- **3** — 蹭到热点但没新视角
- **5** — 用 280 字命名了一个大家感受到但没人说出来的结构
### SAT — Satire Depth(讽刺 / 语调深度)
*这条的语调有多少"装置感"——阴阳、反讽、戏仿?*
- **0** — 真诚直陈
- **3** — 一层阴阳 / 反讽
- **5** — 嵌套反讽 / 自指——"一条讽刺讽刺的帖子"
短文平台上讽刺 / 阴阳是传播利器。如果你的风格是真诚向,SAT 给 2-3 当占位。
---
## Bucket 预测:比率桶方案
> ⚠️ **新人的 bucket 不能用绝对数字**——大 V 的"1000 转算塌房"对新号来说是梦想数据。
### 第 1 条:平台通用默认
X / 微博 / 即刻新号第 1 条的典型分布:
| Bucket | 范围(曝光 / 互动)| 先验概率 |
|---|---|---|
| 底部 | < 50 曝光 | 30% |
| 基础盘 | 50 - 500 曝光 | 40% |
| 命中 | 500 - 5,000 曝光 | 20% |
| 小爆 | 5,000 - 50,000 曝光 | 8% |
| 大爆 | > 50,000 曝光 | 2% |
> 即刻以互动(点赞 + 评论)为主要指标,曝光数据不透明——用互动数代替。
### 第 2 条起:用比率桶
`baseline = 上一条实际曝光 / 互动`(或最近 3 条中位数)。
| Bucket | 倍数范围 | 含义 |
|---|---|---|
| 退步 | < 0.3 × baseline | 明显下滑 |
| 持平 | 0.3 - 1 × baseline | 同档 |
| 命中 | 1 - 3 × baseline | 中度突破 |
| 小爆 | 3 - 10 × baseline | 显著破圈 |
| 大爆 | > 10 × baseline | 量级跃迁 |
第 5 条后跑 `/cheat-bump --bucket-only` 重新校准边界。
---
## Cold-start 战略(简版)
前 5 条的"预测"不是预测——是**数据采集**。
1. **建立纪律**:发布前写下盲判断
2. **记录 7 维评分**:复盘后每条样本变成 (打分, 实绩) 配对
3. **取样策略**:主动选维度组合差异最大的帖子——1 条 ER 主导、1 条 SR 主导、1 条 SAT 主导(阴阳怪气)、1 条 HP 极强但内容一般、1 条综合中等
| 校准样本 | 你能相信什么 |
|---|---|
| N=0-2 | 啥都别信 |
| N=3-5 | 相信"哪个维度可能重要"的方向 |
| N=5-10 | 相信 bucket 排序 |
| N=10-20 | 中枢可信 ±30% |
| N≥20 | rubric 真正成为"作弊器" |
---
## 重要警告
- cold-start 期 composite 的置信度低——**不要基于 v0 分数决定要不要发某条帖子**
- 短文发布频率高——5 条校准可以在 1-2 周内完成,**不要拖**
- 前 5 条**每条必须走完整闭环**(predict → 发布 → retro),跳过任何一条复盘 = 整个校准失败
- 第 5 条后**必须**跑 `/cheat-bump` 升级
---
## 最重要的一句话
**前 5 条你不是在做决策,你是在收集数据。** 短文的好处是迭代快——别浪费这个优势。
+147
View File
@@ -0,0 +1,147 @@
# Starter Rubric:教程 / How-to 内容 — v0 cold-start 占位
**这是给完全没数据的新作者用的占位 rubric。**它会打错。**前 5 篇预测精度大概 ±50%——这是 cold-start 的数学事实,不是 rubric 失败**
跑完 5 篇(每篇都走完 `/cheat-predict` → 发布 → `/cheat-retro` 闭环)之后,你会有第一份个人校准数据,可以提议第一次 `/cheat-bump` 升级到 v1。
---
## v0 综合分公式(**等权占位**)
```
composite = (ER + HP + QL + NA + AB + SR + SAT) / 7 × 2.0
```
每个维度 0-5 整数分。综合分范围 0-10。
**为什么等权**:你没有数据支持任何特定权重。等权能让你在数据出来之前对每个维度都给"中立的关注"。第 5 篇之后你的复盘数据会告诉你哪些维度真的预测了你的教程的传播。
---
## 7 个维度
### ER — Emotional Resonance"啊哈"时刻)
*读者 / 观众跟完教程后,能不能产生**"原来这么简单"或"终于搞懂了"**的顿悟感?*
教程的情感共鸣不是催泪——是认知突破带来的满足感和掌控感。
- **0** — 纯步骤搬运;跟完了但没有"懂了"的感觉
- **3** — 能解决问题,但方式平淡、缺乏洞察
- **5** — 教程本身揭示了一个更深的原理,读者不仅学会了"怎么做"还理解了"为什么"
### HP — Hook Potential(问题陈述清晰度)
*开头能不能在 10 秒内让读者确认"这就是我要解决的问题"*
教程的钩子 = 精准的问题描述。读者搜到你的教程时,开头必须让他确认"找对了"。
- **0** — 标题模糊("Python 学习笔记");开头在介绍技术历史
- **3** — 问题描述清晰但缺乏"痛点共鸣""本文介绍如何..."
- **5** — 开头就是读者的具体痛点场景("你在本地跑得好好的,部署到服务器就报错...")
### QL — Quotable Lines(可复制内容密度)
*教程里有没有"能直接 copy-paste 用的"代码片段、命令、配置、步骤清单?*
教程的"金句"就是可直接复用的内容片段——读者收藏教程的核心原因。
- **0** — 全是概念讲解,没有可直接使用的内容
- **3** — 有代码 / 步骤但需要大量修改才能用
- **5** — 多个可直接复制使用的代码片段 / 命令 / 配置模板,有清晰注释
### NA — Narrativity(教程递进感)
*教程有没有清晰的从简到难的递进弧线?*
- **0** — 步骤之间没有逻辑关系;可以随机跳读
- **3** — 有线性顺序但缺乏"每一步为什么在这里"的解释
- **5** — 明确的递进(先跑通最小示例 → 逐步添加功能 → 处理边界情况),读者能感受到能力在增长
### AB — Audience Breadth(问题普遍性)
*这个教程解决的问题有多少人会遇到?*
- **0** — 极小众工具 / 极特殊场景
- **3** — 特定技术栈的常见问题(如某框架的部署问题)
- **5** — 几乎所有同领域从业者都会遇到的问题(如 Git 合并冲突、环境配置)
### SR — Social Resonance(技术趋势共振)
*这个教程的话题与当下的技术趋势 / 行业热点有关吗?*
- **0** — 已过时的技术 / 纯经典内容
- **3** — 稳定技术栈的常规教程
- **5** — 正处在风口上的技术(如当前的 AI 工具链)——搜索量正在飙升
### SAT — Satire Depth(教学人格感)
*教程在"讲人话"和展示个性方面做到什么程度?*
教程的"讽刺深度"不是讽刺——是**教学人格**:你是一个有温度的人在教,还是一份文档在输出。
- **0** — 纯文档式;没有任何个性
- **3** — 有自然的口语化表达 / 偶尔的吐槽("这个 API 的设计确实反人类")
- **5** — 有鲜明教学人格——幽默、自嘲、对坑的真实愤怒——读者会"因为喜欢你的风格"关注你
如果你的教程走严谨技术文档路线,SAT 给 2-3 当占位即可。
---
## Bucket 预测:比率桶方案
> ⚠️ **新人的 bucket 不能用绝对数字**——头部技术博主的"1w 阅读是正常"对新号来说不适用。
### 第 1 篇:平台通用默认
技术博客 / 掘金 / CSDN / Dev.to / YouTube 教程频道新号第 1 篇的典型分布:
| Bucket | 范围(阅读 / 播放数)| 先验概率 |
|---|---|---|
| 底部 | < 50 | 25% |
| 基础盘 | 50 - 500 | 40% |
| 命中 | 500 - 5,000 | 25% |
| 小爆 | 5,000 - 50,000 | 8% |
| 大爆 | > 50,000 | 2% |
> 教程类内容有长尾效应——发布后 1-6 个月内持续有搜索流量。Bucket 评估建议在发布 30 天后做第一次。
### 第 2 篇起:用比率桶
`baseline = 上一篇发布 30 天后的实际阅读数`(或最近 3 篇中位数)。
| Bucket | 倍数范围 | 含义 |
|---|---|---|
| 退步 | < 0.3 × baseline | 明显下滑 |
| 持平 | 0.3 - 1 × baseline | 同档 |
| 命中 | 1 - 3 × baseline | 中度突破 |
| 小爆 | 3 - 10 × baseline | 显著破圈 |
| 大爆 | > 10 × baseline | 量级跃迁 |
第 5 篇后跑 `/cheat-bump --bucket-only` 重新校准边界。
---
## Cold-start 战略(简版)
前 5 篇的"预测"不是预测——是**数据采集**。
1. **建立纪律**:发布前写下盲判断
2. **记录 7 维评分**:复盘后每条样本变成 (打分, 实绩) 配对
3. **取样策略**:主动选维度组合差异最大的教程——1 篇深度原理 (ER 主导)、1 篇热门工具速成 (SR 主导)、1 篇代码片段集 (QL 主导)、1 篇完整项目实战 (NA 主导)、1 篇综合中等
| 校准样本 | 你能相信什么 |
|---|---|
| N=0-2 | 啥都别信 |
| N=3-5 | 相信"哪个维度可能重要"的方向 |
| N=5-10 | 相信 bucket 排序 |
| N=10-20 | 中枢可信 ±30% |
| N≥20 | rubric 真正成为"作弊器" |
---
## 重要警告
- cold-start 期 composite 的置信度低——**不要基于 v0 分数决定要不要发某篇教程**
- 教程有长尾流量——retro 建议在发布 30 天后做,不要看 Day 1 数据就下结论
- 前 5 篇**每篇必须走完整闭环**(predict → 发布 → retro),跳过任何一篇复盘 = 整个校准失败
- 第 5 篇后**必须**跑 `/cheat-bump` 升级
---
## 最重要的一句话
**前 5 篇你不是在做决策,你是在收集数据。** 教程的长尾特性意味着你需要更多耐心——给每篇足够的时间窗口再做复盘。
+86
View File
@@ -0,0 +1,86 @@
# 受众画像 audience.md
**Persona 版本**: v0
**Last rebuilt**: (未生成——跑 /cheat-persona
**数据基础**: 0 篇复盘 / 0 条评论
**Confidence**: 🔴 无数据(占位骨架)
> ⚠️ **本文件由 `/cheat-persona` 从复盘评论派生——含已发布作品的实绩信号。**
> Channel B`cheat-score-blind` sub-agent**硬禁读本文件**refusal_code `blocked_audience`)。
> persona 影响 cheat-seed 写什么(creative),不影响 cheat-predict 怎么打分(blind)。
---
## 这文件是干嘛的
`audience.md` 是你账号的**受众画像**——"谁在看 / 谁会留评论 / 谁会转发"。
它和 `rubric_notes.md` 平行,是复盘数据的第二个派生物:
```
复盘数据(评论 + 完播 + 转粉)
├──→ rubric 进化(cheat-bump —— "怎么打分"
└──→ 受众画像(cheat-persona —— "谁在看"
```
cheat-seed 选题 / 写稿时读它,多一面"这个 persona 会在乎吗"的镜子。
**它不是手写的**——由 `/cheat-persona``predictions/*.md` 的复盘段评论聚类派生。手写的 persona 是你的幻想,数据派生的 persona 才是你真实的受众。
---
## 核心画像
(待 /cheat-persona 生成——100-150 字散文速写:这个人是谁,为什么来看你)
---
## 验证特征(有评论证据)
| 特征 | 证据(评论条数 + 出处) | 强度 |
|---|---|---|
| (待生成) | | |
> 每条特征**必须**带评论证据 + 条数。没有证据的不写进这一段——写到下面"假设特征"。
---
## 假设特征(待验证)
- (推测但评论证据不足的特征。下次复盘有新评论时 cheat-persona 会尝试验证 / 推翻)
---
## 反画像(数据证明 NOT 我的受众)
- (你曾经以为是受众、但评论数据否定的。这一段防止 persona 变成讨好自己的虚构)
---
## 受众的语言
(他们在评论区怎么说话——玩梗 / 真诚倾诉 / 抬杠 / 复制金句?引 2-3 条真实评论作例)
---
## 选题食欲
| 主题类型 | persona 反应 | 证据 |
|---|---|---|
| (待生成——哪类主题把 persona 勾出来,哪类勾来路人) | | |
---
## persona × rubric 交叉检验
cheat-persona 跑时做的诚实检查:persona 说"受众爱 X",但 rubric 校准池显示 X 类主题 underperform → 在这里 flag 出来。两个派生物互相矛盾时,是重要信号——可能 persona 在自欺,也可能 rubric 漏了维度。)
(待生成)
---
## 版本历史
- v0 — cheat-init 创建空骨架
cheat-persona 每次 rebuild 在此追加一行:`vN — 基于 M 篇复盘 / K 条评论,主要变化:...`
+100
View File
@@ -0,0 +1,100 @@
# 对标账号:[BENCHMARK-NAME]
> **本文件由 `/cheat-learn-from` 创建和维护**。
>
> 前期工具的 rubric / pattern / 选题方向感**大量**从这里推——这是你刚 init 时还没自己历史数据的 anchor。
>
> 当你自己累计 N≥10 校准样本后,benchmark 影响**自然减弱**——你的真实数据成为主要信号源。但 benchmark 不删,保留作 sanity check(看你账号是否真的偏离对标方向)。
>
> 你随时可以:
> - **追加**对标视频:`/cheat-learn-from --append <new-videos>`
> - **替换**对标账号:`/cheat-learn-from --replace <new-account>`
---
## 账号信息
- **名字**: [对标账号名]
- **平台**: [抖音 / B站 / YouTube / 公众号 / ...]
- **URL**: [账号主页]
- **粉丝量级**: [用户提供的,参考用——比如 "1w / 10w / 100w"]
- **风格 / 调性**: [用户描述——例 "知识科普 / 学术戏仿" / "个人吐槽" / "技术教程"]
- **导入时间**: [YYYY-MM-DD]
- **样本数**: N
---
## 导入的样本
| # | 视频标题 | 播放 | 点赞 | 评论 | 转发 | 你的印象 | transcript |
|---|---|---|---|---|---|---|---|
| 1 | [示例] 怎么停止期待 | 71w | 2.4w | 899 | 1.8w | 高 | samples/[BENCHMARK-NAME]/ab61ed09/transcript.md |
| 2 | [示例] 老板废话 | 39w | 1.2w | 567 | 7.9k | 中 | samples/[BENCHMARK-NAME]/5fe5d869/transcript.md |
| 3 | [示例] 谁问你了 | 11w | 3.8k | 198 | 2.7k | 低 | samples/[BENCHMARK-NAME]/8b5627e6/transcript.md |
> **印象档**:高 / 中 / 低 是你看完这条视频后**主观**判断的"算不算这个账号的代表作"——不是数据驱动,是直觉判断。这个判断比数据更能告诉 Claude 你想做什么风格。
---
## 基础 rubric 派生(init 时一次性,**仅给定性方向**)
> 基于 N 条对标样本观察,Claude 总结哪些维度看起来跟"高表现"相关、哪些不太相关。
> **不直接给数值权重**——5-10 样本数值拟合容易过拟合。给方向就行,用户决定要不要在 rubric_notes.md 调权重。
### 高表现样本共有的维度(看起来重要)
- [示例] **ER (情感共鸣) 高**3/3 高表现样本都有强情感锚点
- [示例] **QL (金句密度) 高**:3/3 高表现样本都有 ≥2 句独立可传金句
- [示例] **MS (模因可挪用性) 高**:3/3 高表现样本评论区出现了挪用句式
### 低表现样本共有的维度(看起来不太重要)
- [示例] **SR (社会议题) 不显著**:低表现样本里 SR=4 也没救
- [示例] **NA (叙事性) 不显著**:高低样本 NA 分布无明显差异
### Claude 给的初始建议
- [示例] 你的对标账号看起来是**情感共鸣 + 金句**驱动型
- 建议初始 rubric 权重在 ER / QL / MS(如启用)上 ×1.5
- **但请等你自己跑 5+ 篇校准再正式 bump**——5 个对标样本不足以下结论
详见 [rubric_notes.md](rubric_notes.md) 的"benchmark-derived initial signals"段。
---
## 基础 patterns 派生
详见 [script_patterns.md](script_patterns.md) 的"对标 [BENCHMARK-NAME] 借鉴"段。
每条 pattern 标 **Imported, untested on my channel** —— 你的频道未必适用,实拍验证后(≥2 次跑出 + 复盘确认有效)再去掉这个标记。
---
## 选题方向感
> cheat-seed brainstorm 时会读这里——根据对标账号的主题分布给提议。
[示例] 对标账号常做的主题类型:
- 暗恋 / 情感戒断(占比 ~40%)
- 学术议题戏仿(占比 ~30%
- 职场观察(占比 ~20%
- 社会议题评论(占比 ~10%
> 你不一定要做完全一样的主题——这只是个 reference frame。
> cheat-seed Mode A/B/C 仍以你的实际想做什么为主。
---
## 维护历史
| 日期 | 操作 | 详情 |
|---|---|---|
| YYYY-MM-DD | 首次导入 | N=3 条样本,方式 [way a 粘文本 / way b whisper] |
---
## 后续何时淡出
-`state.calibration_samples >= 10` 时,cheat-status 会提示"你已有足够自己数据,benchmark 主要作 sanity check 不再主导"
- 但 benchmark.md **不删**——它仍是 cheat-seed brainstorm 的 reference frame
- 如果对标账号本身风格变了 / 你不想再对标了 → 跑 `/cheat-learn-from --replace <none>` 解除(保留历史在文件,但 cheat-seed 不再读)
+56
View File
@@ -0,0 +1,56 @@
{
"_comment": "Reference JSON shape for a candidate item. See shared-references/candidate-schema.md for full spec. Adapter implementations (in adapters/) emit arrays of items conforming to this shape. The user-visible storage is candidates.md (markdown) — JSON is only used in machine-to-machine transit (e.g. md-to-sqlite migration, trend-source adapter output).",
"candidates": [
{
"id": "e7c2f1a4d3b6",
"title": "\"为你好\"高密度家庭体系",
"source": "pool:manual",
"snapshot_text": "「为你好」三个字是中国家庭对子女控制的最高级修辞,背后是一整套「我比你更懂你的需要」的认知体系。",
"snapshot_at": "2026-05-01T10:00:00+08:00",
"url": null,
"tier": "tier1",
"read_status": "deep_read",
"category": "家庭",
"composite_score": 9.18,
"dimension_scores": {"ER": 5, "HP": 5, "QL": 4, "NA": 4, "AB": 5, "SR": 5, "SAT": 4},
"scored_under_rubric_version": "v2",
"predicted_bucket": "30-100w",
"predicted_reason": "ER+SR 双 5 顶配,高密度家庭议题普适且分享安全",
"note": "议题厚重,不适合连续 2 篇都打这种"
},
{
"id": "229f5798b1d8",
"title": "哈哈长度",
"source": "pool:manual",
"snapshot_text": "社交媒体中\"哈哈\"长度与沟通意愿的非线性关系研究。",
"snapshot_at": "2026-05-03T09:00:00+08:00",
"url": null,
"tier": "tier1",
"read_status": "deep_read",
"category": "社交",
"composite_score": 8.71,
"dimension_scores": {"ER": 3, "HP": 5, "QL": 5, "NA": 4, "AB": 5, "SR": 4, "SAT": 5},
"scored_under_rubric_version": "v2",
"predicted_bucket": "30-100w",
"predicted_reason": "v2.1 候选维度 MS+TS 双 5——v2.1 升级的关键 A/B 验证样本",
"note": ""
},
{
"id": "8c4d92e1f0b3",
"title": "Show HN: I built a tool that predicts whether your video will go viral",
"source": "trend:hackernews",
"snapshot_text": "Built over a weekend. Uses a simple multidimensional rubric calibrated against creator's own historical data. Curious what the HN crowd thinks of the methodology.",
"snapshot_at": "2026-05-04T09:15:00+08:00",
"url": "https://news.ycombinator.com/item?id=12345678",
"tier": null,
"read_status": "unread",
"category": "tech-meta",
"composite_score": null,
"dimension_scores": null,
"scored_under_rubric_version": null,
"predicted_bucket": null,
"predicted_reason": null,
"note": ""
}
]
}
+118
View File
@@ -0,0 +1,118 @@
# 候选选题池
> **本文件由 `/cheat-trends` 写入热点抓取结果,由 `/cheat-recommend` 读取并排序。
> 也可手动编辑——把候选标题贴成 H3 entry 即可。**
>
> Schema 完整规范:[shared-references/candidate-schema.md](../cheat-on-content/shared-references/candidate-schema.md)。
---
## 使用说明(首次见到本文件请读)
每个候选项是一个 H3 entry`### [tier] 标题`),下面带 metadata bullets。最简版本只需要 title 一行——`/cheat-recommend` 会自动调 `/cheat-score` 给未打分的 entry 粗打分。
### 字段含义速查
- **id**:12 位 hash,用于跨文件去重。手加 entry 留空,`/cheat-trends` 自动算
- **source**:来源标识,格式 `<adapter-type>:<source-name>`
- **snapshot_at**:抓取 / 录入时间(ISO 8601 或 YYYY-MM-DD
- **tier**:粗分类 `tier1` / `tier2` / `tier3` / `skip` / `risky` / `done`
- **read_status**`unread` / `skimmed` / `deep_read` / `done`
- **composite (vN)**:当前 rubric 下的综合分(粗打分,**不是预测**)
- **predicted bucket**:粗预测桶(粗略,仅用于排序)
- **note**:备注(如"等节点再发"、"待重读"、"风险议题"
### 手加 entry 的最简格式
```markdown
### 标题
- snapshot_at: 2026-05-04
```
剩下的字段会被 `/cheat-recommend` 在下次调用时补全(自动调 `/cheat-score`)。
---
## 候选项
> 删除下方所有示例 entry,从你的真实候选开始累计。
> 示例展示的是「视频分析」项目当前 candidates pool 的真实样本(已脱敏)。
### [tier1] "为你好"高密度家庭体系
- **id**: e7c2f1a4d3b6
- **source**: pool:manual
- **snapshot_at**: 2026-05-01
- **tier**: tier1
- **read_status**: deep_read
- **composite (v2)**: 9.18 — ER=5 HP=5 QL=4 NA=4 AB=5 SR=5 SAT=4
- **predicted bucket**: 30-100w(中枢 ~60w
- **note**: 议题厚重,不适合连续 2 篇都打这种
> 「为你好」三个字是中国家庭对子女控制的最高级修辞,背后是一整套「我比你更懂你的需要」的认知体系。
> [snapshot_text 段——deep_read 后的精读笔记,可选]
---
### [tier1] 哈哈长度
- **id**: 229f5798b1d8
- **source**: pool:manual
- **snapshot_at**: 2026-05-03
- **tier**: tier1
- **read_status**: deep_read
- **composite (v2)**: 8.71 — ER=3 HP=5 QL=5 NA=4 AB=5 SR=4 SAT=5
- **predicted bucket**: 30-100w(中枢 ~55w
- **note**: v2.1 候选维度 MS+TS 双 5——v2.1 升级的关键 A/B 验证样本
> 社交媒体中"哈哈"长度与沟通意愿的非线性关系研究。
> 与谁问你了构成完美 A/B 对照——同 ER/HP/QL/SR/SAT,差异仅在 MS 和 TS 各 +3。
---
### [tier1] 弗洛伊德的性压抑哲学
- **id**: 8c4d92e1f0b3
- **source**: trend:manual-paste
- **snapshot_at**: 2026-04-28
- **tier**: tier1
- **read_status**: skimmed
- **composite (v2)**: 9.53 — ER=4 HP=5 QL=5 NA=4 AB=5 SR=5 SAT=5
- **predicted bucket**: 30-100w(中枢 ~70w
- **note**: **risky**(性议题),需评估账号 risk tolerance
> 弗洛伊德的核心理论被 21 世纪心理学界否定,但他对「压抑→症状」的描述在亲密关系实务里仍有解释力。
---
### [skip] [示例 - 已跳过的候选]
- **id**: a1b2c3d4e5f6
- **source**: trend:hackernews
- **snapshot_at**: 2026-05-02
- **tier**: skip
- **rejected_at**: 2026-05-02
- **rejected_reason**: 与本账号议题不符(纯技术新闻)
---
### [done] [示例 - 已发布的候选]
- **id**: ab61ed09f0a1
- **source**: pool:manual
- **snapshot_at**: 2026-04-22
- **tier**: done
- **read_status**: done
- **composite (v2)**: 8.24
- **published_at**: 2026-04-24
- **predictions_file**: predictions/2026-04-24_ab61ed09_停止期待.md
> done tier 的 entry **不出现在 `/cheat-recommend` 的输出**——已发过的不再推。
---
## 维护建议
- **保持 < 100 条 active**tier1+tier2+tier3,不含 skip/done)。超过这个数排序不稳
- **定期清理 skip**:超过 6 个月的 skip 可以从 `.cheat-cache/trends-history.jsonl` 自动剔除(`/cheat-trends` 重复抓到时会再出现)
- **risky 标签认真用**`/cheat-recommend` 会高亮 risky 项让你二次确认;不要把所有"有点争议"都标 risky,会麻木
+181
View File
@@ -0,0 +1,181 @@
-- cheat-on-content / content.db.schema
--
-- SQLite schema for the calibration pool when scale demands it.
-- Schema is largely lifted from the 视频分析 reference implementation:
-- - articles: one row per piece of content (candidate or published)
-- - scoring_history: append-only log of every scoring event (version traceability)
-- - bumps: append-only log of rubric upgrades
--
-- Created by tools/md-to-sqlite.py during the markdown → SQLite migration
-- (typically triggered when calibration_samples >= 30).
--
-- After migration, predictions/*.md remain on disk as the human-readable
-- source of truth. articles.db is the queryable secondary index.
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
-- ==================== articles ====================
-- One row per piece of content. Mirrors the candidate-schema.md fields plus
-- the lifecycle fields (published, performance, retro).
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY, -- 12-char sha256 prefix
title TEXT NOT NULL,
source TEXT NOT NULL, -- e.g. 'pool:manual', 'trend:hackernews'
snapshot_text TEXT, -- the scoring input (full text or summary)
snapshot_at TEXT NOT NULL, -- ISO 8601
url TEXT,
category TEXT,
tier TEXT CHECK (tier IS NULL OR tier IN ('tier1','tier2','tier3','skip','risky','done')),
read_status TEXT CHECK (read_status IS NULL OR read_status IN ('unread','skimmed','deep_read','done')),
note TEXT,
-- Scoring (current values; history in scoring_history)
emotional_resonance INTEGER CHECK (emotional_resonance IS NULL OR (emotional_resonance BETWEEN 0 AND 5)),
social_resonance INTEGER CHECK (social_resonance IS NULL OR (social_resonance BETWEEN 0 AND 5)),
hook_potential INTEGER CHECK (hook_potential IS NULL OR (hook_potential BETWEEN 0 AND 5)),
quotable_lines INTEGER CHECK (quotable_lines IS NULL OR (quotable_lines BETWEEN 0 AND 5)),
narrativity INTEGER CHECK (narrativity IS NULL OR (narrativity BETWEEN 0 AND 5)),
audience_breadth INTEGER CHECK (audience_breadth IS NULL OR (audience_breadth BETWEEN 0 AND 5)),
satire_depth INTEGER CHECK (satire_depth IS NULL OR (satire_depth BETWEEN 0 AND 5)),
-- Future-version dimensions (NULL until rubric upgrades introduce them)
memetic_shareability INTEGER CHECK (memetic_shareability IS NULL OR (memetic_shareability BETWEEN 0 AND 5)),
topic_shareability INTEGER CHECK (topic_shareability IS NULL OR (topic_shareability BETWEEN 0 AND 5)),
composite_score REAL,
scored_under_rubric_version TEXT,
-- Prediction (latest snapshot from predictions/<id>.md)
predicted_plays_bucket TEXT, -- e.g. '30-100w'
prediction_reason TEXT, -- one-line summary
prediction_file TEXT, -- path to immutable prediction file
blind_status TEXT CHECK (blind_status IS NULL OR blind_status IN ('confirmed_no_data_seen', 'reconstructed', 'integrity_warning')),
-- Publish lifecycle
published_at TEXT, -- ISO 8601
platform TEXT, -- 'youtube', 'bilibili', 'douyin', 'wechat', etc.
platform_url TEXT,
matched_video_folder TEXT, -- path to videos/<...>/
-- Performance (T+N data, populated by /cheat-retro)
actual_plays INTEGER,
actual_likes INTEGER,
actual_comments INTEGER,
actual_shares INTEGER,
actual_saves INTEGER,
performance_synced_at TEXT, -- ISO 8601 of last data fetch
performance_window_days INTEGER, -- T+N where N is
-- Bookkeeping
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_articles_tier ON articles(tier);
CREATE INDEX IF NOT EXISTS idx_articles_read_status ON articles(read_status);
CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_composite ON articles(composite_score DESC);
CREATE INDEX IF NOT EXISTS idx_articles_rubric_ver ON articles(scored_under_rubric_version);
-- updated_at trigger
CREATE TRIGGER IF NOT EXISTS articles_updated_at
AFTER UPDATE ON articles
FOR EACH ROW
BEGIN
UPDATE articles SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- ==================== scoring_history ====================
-- Append-only log of every scoring event. Used for version traceability,
-- rollback, and counterfactual analysis ("what would the v3 score have been
-- for this v2-scored article?").
CREATE TABLE IF NOT EXISTS scoring_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id TEXT NOT NULL REFERENCES articles(id),
rubric_version TEXT NOT NULL,
scored_at TEXT NOT NULL DEFAULT (datetime('now')),
scored_by TEXT, -- 'auto' / 'manual' / 'bump-rescore' / etc.
-- Scores at the time of this event
emotional_resonance INTEGER,
social_resonance INTEGER,
hook_potential INTEGER,
quotable_lines INTEGER,
narrativity INTEGER,
audience_breadth INTEGER,
satire_depth INTEGER,
memetic_shareability INTEGER,
topic_shareability INTEGER,
composite_score REAL,
-- Free-form note (e.g. "post-hoc rescore for v2.1 bump validation")
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_scoring_history_article ON scoring_history(article_id);
CREATE INDEX IF NOT EXISTS idx_scoring_history_rubric ON scoring_history(rubric_version);
CREATE INDEX IF NOT EXISTS idx_scoring_history_scored ON scoring_history(scored_at);
-- ==================== bumps ====================
-- Append-only log of rubric upgrades. One row per successful bump.
CREATE TABLE IF NOT EXISTS bumps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_version TEXT NOT NULL,
to_version TEXT NOT NULL,
bumped_at TEXT NOT NULL DEFAULT (datetime('now')),
formula_before TEXT NOT NULL, -- full formula string
formula_after TEXT NOT NULL,
calibration_pool_size INTEGER NOT NULL,
rank_consistency REAL, -- e.g. 0.8 for 4/5
pairwise_no_regression BOOLEAN,
cross_model_audit TEXT CHECK (cross_model_audit IS NULL OR cross_model_audit IN ('PASS', 'REJECT', 'SKIPPED')),
cross_model_reasoning TEXT, -- audit reviewer's verbatim reasoning
memo TEXT, -- human-written upgrade memo
triggered_by TEXT -- e.g. 'consecutive_directional_errors'
);
-- ==================== views ====================
-- Convenience views for common queries.
CREATE VIEW IF NOT EXISTS calibration_pool AS
SELECT
a.*,
(CAST(a.actual_plays AS REAL) / 10000.0) AS actual_plays_w
FROM articles a
WHERE a.actual_plays IS NOT NULL
AND a.published_at IS NOT NULL
AND a.prediction_file IS NOT NULL
ORDER BY a.composite_score DESC;
CREATE VIEW IF NOT EXISTS pending_retros AS
SELECT a.*
FROM articles a
WHERE a.published_at IS NOT NULL
AND a.actual_plays IS NULL
ORDER BY a.published_at ASC;
CREATE VIEW IF NOT EXISTS top_candidates AS
SELECT a.*
FROM articles a
WHERE a.tier IN ('tier1', 'tier2')
AND a.published_at IS NULL
AND a.composite_score IS NOT NULL
ORDER BY
CASE a.tier WHEN 'tier1' THEN 0 WHEN 'tier2' THEN 1 ELSE 2 END,
a.composite_score DESC;
-- ==================== schema_meta ====================
-- Schema version for future migrations.
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR REPLACE INTO schema_meta (key, value) VALUES
('schema_version', '1.0'),
('created_at', datetime('now')),
('source', 'cheat-on-content templates/content.db.schema.sql');
+44
View File
@@ -0,0 +1,44 @@
# cheat-on-content — 内容项目 .gitignore
# 由 /cheat-init 生成。目的:保护账号凭证 + 本地状态,同时让你的创作产物
# predictions/ videos/ scripts/ 等)正常进入版本控制——原则 #1/#3 依赖
# git history 作为预测的不可变档案,所以这些目录**不**忽略。
#
# 注意:.gitignore 的注释必须独占一行,行内 `#` 不是注释(会变成 pattern 的一部分)。
# ── 凭证 / 登录态:绝不提交(提交即等于泄露账号)──
# .auth/ = 抖音 Playwright 登录态(含 sessionid)
# .auth-xhs/ = 小红书 Playwright 登录态(含 web_session 等)
# .auth-linkedin/ = LinkedIn Playwright 登录态(含 li_at)
# .cheat-secrets.json = API key / cookie
.auth/
.auth-xhs/
.auth-linkedin/
.cheat-secrets.json
# ── 本地缓存与生成物 ──
# .cheat-cache = 使用日志/热点去重缓存;.cheat-hooks = init 生成的 hook 副本;.debug = adapter 调试输出
.cheat-cache/
.cheat-hooks/
.debug/
# ── Claude Code 本地配置(含机器相关路径 / 个人 permission)──
.claude/
# ── Pythonadapter / 工具脚本跑过留下)──
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.python-version
# ── macOS / 编辑器 / 临时文件 ──
.DS_Store
._*
.vscode/
.idea/
*.swp
*.swo
*~
*.tmp
*.log
+137
View File
@@ -0,0 +1,137 @@
# [作品标题] — 预测日志
> **本模板由 `/cheat-predict` 自动填充**。
> 所有预测都用统一格式(7 个组件 + 复盘段)——前 5 篇和成熟期都一样,区别仅是 header 里的 confidence 等级。
>
> 完整规范见 [shared-references/prediction-anatomy.md](../cheat-on-content/shared-references/prediction-anatomy.md)。
>
> 模板里的示例数据来自 视频分析 项目「停止期待」([predictions/2026-04-24_ab61ed09_停止期待.md])。
---
**Article ID**: `<12 位 hash>` ← e.g. ab61ed09f0a1(对 `scripts/<id>.md` 首次落盘内容做 sha256,取前 12)
**Title**: `<完整标题>`
**Rubric Version**: **`<v0/v1/v2/...>`**
**预测时间**: `<YYYY-MM-DD>`(基于最终稿)
**Script Path**: `scripts/<YYYY-MM-DD>_<id>_<short>.md`
**Script Hash**: `sha256:<12 位>` (predict 时 hash script 内容;cheat-shoot 时再 hash,不一致则复盘段加 integrity warning)
**Target Duration (s)**: `<state.typical_duration_seconds>` ← e.g. 240 (3-5min)
**Actual Script Length**: `<script.md 实际字数>` ← e.g. 980 字
**Calibration Samples (at predict time)**: `<state.calibration_samples>` ← e.g. 3
**Confidence**: `<emoji + 标签>` ← e.g. 🟡 偏低 (中枢 ±40%,可作为参考之一)。从 calibration_samples 派生,见 state-management.md
**Scored By**: `claude` | `claude+user_override` ← Claude 自打分;如用户在 review 阶段挑刺改了字段,标 `+user_override`
**User Override**: `<如有覆盖,列出哪些字段被改了+原值与新值>` | `none`
← 例:`AB: claude=4 → user=3 (用户认为 '一人公司题没那么普适')` `中枢: claude=60w → user=40w`
← 复盘时这个字段帮诊断:用户哪个维度直觉跟 Claude 系统性偏离,被实绩验证 → rubric 可能漏了什么
**预测时数据状态**: **blind**(未看任何 `<平台>` 实际播放数据)
**Prediction Basis**: `pre_shoot` ← 或 `post_shoot_pre_publish`v2 段)
**BlindScored By**: `subagent-v1` ← 或 `main-claude-self` / `mixed`
**BlindScore Disagreement**: `<inline JSON — 每维度一条,见 prediction-anatomy.md>`
---
## 输入快照
**分数 (vN)**: `<dim1=X / dim2=X / ...>` → composite=**`<X.XX>`**
> 示例:ER5 / HP5 / QL5 / NA3 / AB5 / SR2 / SAT4 → composite=**8.24**
**用户改写要点 vs Claude 草稿**(如有):
- **开头**:[user 砍掉了什么 / 加了什么]
- **砍掉**:[具体段落 / 概念名 / 铺垫]
- **保留**:[关键的金句 / 致谢段 / 主体结构]
- **节奏**:比草稿 [紧 / 松] 约 N%
> 如用户从零写没用 cheat-seed,写"用户原创稿,无 Claude 草稿对照"。
---
## 预测 v1
> ⚠️ **本段是 immutable**——`hooks/prediction-immutability.sh` 会拦截对本段的 Edit。
> 写完不可改。如要重做请创建 `<本文件名>_redo.md`,原文件保留。
**Bucket**: `<X-Yw>` ← e.g. `30-100w`
**内心概率分布**:
- `<5w` → X%
- `5-30w` → X%
- **`<headline bucket>` → X%**(中枢 ~Xw
- `>100w` → X%
- `>150w` → X%
> 加起来必须 100%。
> Confidence 低(calibration_samples 少)时**应该更平**(如 30/30/20/15/5),不是更尖(如 5/40/45/8/2)——诚实地反映不确定。
**一句话 reason**:
> [核心驱动因素 + 最强反例约束 + 中枢预测]
---
## 推理因素
| 因素 | 方向 | 置信度 | 说明 |
|---|---|---|---|
| `<dim or feature>` | 强 + / 中 + / 弱 ? / 强 - | 高 / 中 / 低 | [≤30 字理由] |
> 置信度三档:高(强证据 + 多锚点支持)、中(有理由但样本少)、低(凭直觉)。复盘时如果"低置信度"因素被验证 → 直觉强;"高置信度"因素被推翻 → rubric 有 bug。
---
## 锚点对比
| 对照样本 | composite | 实绩 | 异同 |
|---|---|---|---|
| `<样本名>` | `<X.XX>` | `<Yw>` | [关键差异维度] |
> **校准池不够时**< 2 个 composite ±0.5 邻近样本):
> 写"校准池只有 N 个样本,无 composite 邻近样本。**锚点对比 N/A**——注意本次预测 confidence 是 🟡 偏低 / 🔴 极低,bucket 中枢仅供参考。"
> **不要直接删这段**——告诉读者锚点为何缺,比静默跳过诚实。
---
## 反事实场景(复盘用)
**如果爆 `>X w`**X% 预期):
- [验证什么假设]
- [推翻什么假设]
- [可能新增什么 rubric 维度]
**如果落在 `headline bucket`**X% 预期):
- [基准线验证什么]
**如果跌到 `<X w`**X% 预期):
- [推翻什么核心判断]
**如果 `<<X w`**X% 预期):
- [极端场景的可能解释]
> 实际落在哪个 bucket → 告诉你 rubric 的哪个假设被测试。**不可省略**。
---
## 关键校准假设
[把这次预测当成一次实验,明确写下"如果 X 发生,证明 Y"]
[找一个对照样本(最好是上一篇预测)]
两篇 [同 composite / 邻近 composite],差异:
- 本篇:[关键维度对比]
- 对照:[关键维度对比]
**我押**:[本篇 vs 对照 = X 倍 / 高 N w]
如果反过来 → [推翻什么 rubric 假设]
如果差距 < N → [rubric 基本 OK / 噪声范围]
> **校准池只有 0-1 篇时**:写"无可对照样本——但仍写下我对这次的核心赌注(即使没有锚点):"然后写 1-2 条这次想测的事。
---
## 复盘
> ⚠️ **以下段落由 `/cheat-retro` 在 T+`RETRO_WINDOW_DAYS` 天后追加**。
> hook 允许追加本段;不允许改预测段任何字符。
(待填——T+RETRO_WINDOW_DAYS 天后跑 `/cheat-retro <对应 video folder>`

Some files were not shown because too many files have changed in this diff Show More