chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
@@ -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
@@ -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 — 受众画像 persona(cheat-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 header(version / 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.5:humanizer 自检 pass(去 AI 味)
|
||||
|
||||
**问题**:Claude 自己写的 cheat-seed 初稿天然带 AI 写作 tells——em-dash 滥用、rule of three、inflated 词汇、空泛归因、-ing 浅层分析。用户拿到的起点"机器味"重。
|
||||
|
||||
**改动**:draft 写完落盘后、展示给用户前,新增 Phase 4.5——用 [`humanizer`](https://github.com/blader/humanizer) skill(MIT,外部项目,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`(长度)
|
||||
- docs(adapter 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.4(MINOR 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-agent(channel 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** = 跨模型 audit(qwen-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.3(MINOR)
|
||||
|
||||
- 新增 `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 switcher(QuantDinger 风格)
|
||||
- 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 类(模型/产品/行业/论文/技巧)。无 auth,curl 公开 API,rate limit 600/min
|
||||
- **[trendradar-mcp](adapters/trend-sources/trendradar-mcp.md)**(MCP server):[TrendRadar](https://github.com/sansan0/TrendRadar)(57k stars,GPL-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 都 BLOCK;append 新段、改 ## 复盘 都 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.2(MINOR)
|
||||
|
||||
- 升级 [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 checklist(bump 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 hook(binary)
|
||||
- `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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
·
|
||||
<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>
|
||||
|
||||
<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** — 5–10 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.*
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`XBuilderLAB/cheat-on-content`
|
||||
- 原始仓库:https://github.com/XBuilderLAB/cheat-on-content
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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` | 已 init;cold-start 强烈建议;后续可随时 --append / --replace |
|
||||
| "找选题" / "我不知道拍什么" / "seed" / "找前 5 个选题" | `/cheat-seed` | 已 init(cold-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 即可。
|
||||
@@ -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 即可。")
|
||||
@@ -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()
|
||||
Executable
+110
@@ -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
|
||||
@@ -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` → 短链需要 resolve(cheat-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
|
||||
@@ -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()
|
||||
Executable
+134
@@ -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-session(LinkedIn 单帖分析爬取)
|
||||
|
||||
被 `/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 header,cheat-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()
|
||||
Executable
+121
@@ -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")
|
||||
@@ -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.md;archive 输出在 `<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 header,cheat-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**。
|
||||
@@ -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_unavailable,cheat-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 Chromium,fallback 到 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,前台评论可能要 manual;galaxy 主路不受影响。")
|
||||
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__。
|
||||
|
||||
返回 dict:success, 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:
|
||||
"""打开前台笔记页 → 拦截 feed(interact_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())
|
||||
@@ -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"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""把抓到的小红书数据渲染成 report.md(cheat-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"")
|
||||
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
|
||||
@@ -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()
|
||||
@@ -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 存在。
|
||||
|
||||
---
|
||||
|
||||
## 安装(一次性)
|
||||
|
||||
### 选项 A:whisper-cpp(**推荐**——快、轻、纯 C++)
|
||||
|
||||
Mac M 系列芯片上一条 3 分钟视频转录 30-60 秒。
|
||||
|
||||
```bash
|
||||
# 1. 装 whisper-cpp
|
||||
brew install whisper-cpp
|
||||
|
||||
# 2. 装 ffmpeg(whisper-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
|
||||
```
|
||||
|
||||
### 选项 B:openai-whisper(Python 版,更慢但有 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 会自动找到。
|
||||
Executable
+133
@@ -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
|
||||
@@ -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 C,content_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。
|
||||
@@ -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. 解析 items(title / 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)。
|
||||
@@ -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(待)— 新榜 / 飞瓜,付费但稳定
|
||||
@@ -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(待)— 付费稳定数据源
|
||||
@@ -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>
|
||||
·
|
||||
<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>
|
||||
|
||||
<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 也是。*
|
||||
*未来从不奖励努力——它只奖励先看见规律的人。*
|
||||
|
||||
*你看到这一行——也是它预测的。*
|
||||
@@ -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 |
@@ -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 |
@@ -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:57(39w)—— 最短
|
||||
- 2:11(11w)
|
||||
- 2:52(71w)
|
||||
- 3:00(124w)
|
||||
|
||||
**不要超过 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. **直接激发 MS(Memetic 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")
|
||||
Executable
+62
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+155
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+218
@@ -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
@@ -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 ""
|
||||
@@ -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-init(content_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 正常读。
|
||||
@@ -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 触发时按需补全。
|
||||
@@ -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
|
||||
@@ -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-agent(channel 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.7w,CC 锚点被预先校准
|
||||
→ 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 7:bump 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
|
||||
```
|
||||
@@ -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 prediction(state 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,需要单独走流程
|
||||
@@ -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 = 1,buffer 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(标 skip)6 个月内不推
|
||||
|
||||
---
|
||||
|
||||
## 节奏元规则
|
||||
|
||||
按优先级(高→低):
|
||||
|
||||
1. **Buffer 优先于评分**:红色警戒时不要因为"等更好的选题"而断更——拍 composite 7.5 的稳分比"等明天的 9.0"安全
|
||||
2. **复盘优先于新拍**:T+RETRO_WINDOW_DAYS 到期当天**先复盘再考虑拍新的**——否则数据信号丢失,rubric 校准受损
|
||||
3. **同步优先于积压**:buffer 满(蓝色)时不要再拍,先发掉再说——已拍议题的时效性会衰减
|
||||
4. **实验性最多 1/天**:每天拍 2 条时至少 1 条是稳分。**不要全实验**——冷启动期实验失败率太高,伤校准节奏
|
||||
|
||||
---
|
||||
|
||||
## 标准化"今日工作流"模板
|
||||
|
||||
### 情况 1:buffer 充足 + 没到 T+3d 复盘
|
||||
|
||||
```
|
||||
SessionStart 报告 → user 决定拍/不拍
|
||||
├─ 拍 → "推荐选题" → cheat-recommend 推 2 个 →
|
||||
│ user 选 → /cheat-seed 写 draft (cold-start) 或 user 自己写 →
|
||||
│ user 改写 → script.md → user 拍 → "拍了 videos/<...>/" → cheat-shoot
|
||||
└─ 不拍 → 等
|
||||
```
|
||||
|
||||
### 情况 2:buffer 充足 + 到 T+3d 复盘
|
||||
|
||||
```
|
||||
SessionStart 报告含 ⏰ 复盘提醒 → user 给 video URL 或粘数据 →
|
||||
cheat-retro 自动跑 → 写复盘段 → 检查 bump 触发条件
|
||||
├─ 触发 → 提议 /cheat-bump(不强制,用户决定)
|
||||
└─ 未触发 → 等下个验证样本
|
||||
```
|
||||
|
||||
### 情况 3:buffer 红色警戒
|
||||
|
||||
```
|
||||
🔴 SessionStart 第一行警戒 → user 决定
|
||||
├─ 拍 → cheat-recommend 只推 v 当前 top 1 稳分 → 立即拍
|
||||
└─ 接受断更风险 → user 自负,cheat-status 持续提示
|
||||
```
|
||||
|
||||
### 情况 4:buffer 蓝色积压
|
||||
|
||||
```
|
||||
🔵 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.shoots,buffer +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 输出 |
|
||||
@@ -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 record,append-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 写)。
|
||||
@@ -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 模型可扩展。
|
||||
@@ -0,0 +1,149 @@
|
||||
# Migration Protocol(schema 演进哲学)
|
||||
|
||||
被 `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 出错更好。
|
||||
|
||||
---
|
||||
|
||||
## 维护者 checklist:bump 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` 通配规则)。
|
||||
@@ -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 的最强外部证据)"被吸收为新维度 **MS(Memetic 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-agent(channel 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.md,sub-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.XX,bucket=Y
|
||||
- 实绩:播放 / 点赞 / 评论 / 转发(带 T+Nd 标注)
|
||||
- Top 评论关键词:[简短摘录 + 赞数]
|
||||
- 判断:哪个维度被验证 / 推翻?为什么?
|
||||
- Rubric 调整:[如果有,写明 "下次打 XX 类文章时改 YY"]
|
||||
- 详见:[predictions/<file>.md]
|
||||
```
|
||||
|
||||
跨视频观察的模板:
|
||||
|
||||
```markdown
|
||||
### 观察 X — [一句话规律]
|
||||
**证据**:
|
||||
- 样本 1(composite, 实绩):核心数据
|
||||
- 样本 2(composite, 实绩):核心数据
|
||||
- 样本 3(composite, 实绩):核心数据
|
||||
|
||||
**假设**:[如果规律成立,意味着什么 rubric 调整]
|
||||
|
||||
样本数:N 个,[已超 / 待补]。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与 bump 协议的衔接
|
||||
|
||||
`cheat-bump` 在 Step 5(cleanup pass)必须按以下顺序处理观察段:
|
||||
|
||||
1. 列出所有 `## 观察记录` 段条目
|
||||
2. 对每条问 3 个问题:
|
||||
- 它是否被本次 bump 吸收为正式维度?→ 删
|
||||
- 它是否被本次 bump 的新数据推翻?→ 删
|
||||
- 它是否仍未解决?→ 迁移到"待验证假设"段
|
||||
3. 列出所有 `## 重大跨视频观察` 段条目
|
||||
4. 对每条问相同 3 个问题
|
||||
5. 把"已被验证但不需改公式"的规律 → 迁移到"规律沉淀区"
|
||||
6. 最后**重新阅读** `rubric_notes.md` 全文,确保读者打开能在 60 秒内理解当下打分规则
|
||||
@@ -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 个必备组件
|
||||
|
||||
### 组件 1:File 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 3,SR 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 |
|
||||
@@ -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.2(MINOR,兼容):
|
||||
|
||||
- **新增 `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.1(MINOR,兼容):
|
||||
|
||||
- **`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-seed(brainstorm 时读 benchmark.md)/ cheat-status(pending 时持续提醒) |
|
||||
| `benchmark_name` | string / null | 对标账号名(如"蜗牛学长留学");none 时为 null | cheat-learn-from | cheat-status / cheat-seed |
|
||||
| `benchmark_sample_count` | int | 已导入的对标视频条数 | cheat-learn-from(写入 / append) | cheat-status(N≥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-bump(CROSS_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-retro(push) / cheat-bump(清空) | cheat-status / cheat-retro 自检 | 最近 N 次复盘的偏差方向;连续 3 同向触发 bump 提议 |
|
||||
| `pending_retros` | array of file path | cheat-publish(push) / cheat-retro(remove) | 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-shoot(push) / cheat-publish(remove) | 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-publish(push)/ cheat-retro(remove) | publish 时 push 本次;retro 完成时 remove |
|
||||
| `consecutive_directional_errors` | cheat-retro(push)/ cheat-bump(清空) | retro 判定偏差方向时 push;bump 落地时清空 |
|
||||
| `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 等级。这是新设计的核心原则。
|
||||
@@ -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 公式 / 加减维度 → --propose;2) 只重新派生 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.5(Memetic Shareability)
|
||||
- 新增 TS ×1.0(Topic 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 仍是 Claude,RLHF 共享 | `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 B:bucket-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 tool(spawn cheat-score-blind)
|
||||
- 修改:
|
||||
- `rubric_notes.md`(结构性更新,**绝不**写真实视频名 / 实绩)
|
||||
- `rubric-memo.md`(**新**——append Memo 全文,含证据 + 派生证据)
|
||||
- 所有 `predictions/*.md`(追加 Re-scored 行,不动预测段)
|
||||
- `.cheat-state.json`
|
||||
- 下游:下一篇 `/cheat-predict` 自动按新 rubric_version 打分
|
||||
@@ -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 adapter(Playwright + 扫码登录抖音创作者中心)
|
||||
> b) 小红书 — 装 xhs-explore adapter(Playwright + 扫码登录小红书创作者中心)
|
||||
> c) YouTube — 装 youtube-data-api adapter(需 API key)
|
||||
> d) B 站 — bilibili-stat adapter
|
||||
> e) LinkedIn — 装 linkedin-session adapter(Playwright + 登录 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 越准。最多到你账号实际数量)"
|
||||
→ 用户给数字 N,Phase 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-from(5-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→0;Q2=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: v0,calibration_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→0;Q2=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`** |
|
||||
@@ -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 + 1b(whisper + 手填)
|
||||
|
||||
### Phase 2: 收集材料
|
||||
|
||||
按 Phase 1a + 1b 的组合走对应路径。
|
||||
|
||||
**通用纪律**:每条样本最少要有 (script 或 transcript 或 N/A标记) + 数据 (4 项基础:播放/点赞/评论/转发)。
|
||||
|
||||
#### Path A: 粘文本(Phase 1a=a)
|
||||
|
||||
```
|
||||
好。我们一条一条来。最少 3 条,推荐 5-10 条。
|
||||
|
||||
第 1 条 script,把整段粘下面(段落版,不要字幕格式):
|
||||
```
|
||||
|
||||
收到 script → 算 video_id(sha256(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-whisper(Python 慢些)
|
||||
- 切回 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: 跳过 script(Phase 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=b(adapter)**:
|
||||
|
||||
```
|
||||
你说装了 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-DD,N 条样本)
|
||||
|
||||
> 这些 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) |
|
||||
@@ -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.1(MINOR)
|
||||
新增字段: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.2(MINOR)
|
||||
新增字段:[...]
|
||||
详见: 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.md(MINOR:新增 platform_metrics_url 等字段)
|
||||
Phase 4: 验证 OK
|
||||
Phase 5: 报告 ✅
|
||||
```
|
||||
|
||||
### 示例 2:用户跳了多版
|
||||
|
||||
```
|
||||
用户:我从 v0.1.0 升到 v0.5.0,state 还是 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 继续。
|
||||
```
|
||||
@@ -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 说 A,rubric 校准说 B,待下次复盘澄清"——矛盾本身是信号。
|
||||
|
||||
### Phase 4: 写 audience.md
|
||||
|
||||
**覆盖式重建**(不是 append)——persona 是活文档,每次 rebuild 重写全文。但:
|
||||
|
||||
- header 的 `Persona 版本` +1(v0 → 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% → 移到反画像
|
||||
- ⚠️ 交叉检验 flag:persona 说受众爱"职场吐槽",但 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.md(refusal_code `blocked_audience`)
|
||||
|
||||
## Known limitations
|
||||
|
||||
1. **评论 ≠ 全部受众**——留评论的是受众里最活跃的一小撮(沉默大多数不在数据里)。persona 偏向"会评论的人",不是"所有看的人"
|
||||
2. **平台评论可被污染**——水军 / 引战 / 跑题评论会进数据。cheat-persona 聚类时对明显异常值降权,但不能完全过滤
|
||||
3. **persona 滞后于真实受众变化**——画像基于过去 N 篇的评论。受众结构变了,要等新复盘累积才反映
|
||||
4. **不解决"我想要的受众 ≠ 我实际的受众"**——persona 只如实报告"现在谁在看"。想转向另一种受众是选题战略问题,cheat-persona 只提供"现状 vs 目标"的 gap,不替你做战略
|
||||
@@ -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. 选择最可能的 bucket(headline 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
|
||||
|
||||
🎯 押 bucket:30-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 篇)
|
||||
|
||||
⚠️ ## 预测段已 immutable(hook 锁定)。
|
||||
⚠️ 这是 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
|
||||
|
||||
⚠️ ## 预测 段已 immutable(hook 锁定)。
|
||||
⚠️ 你不能再向我"透露"这条作品的播放数据,否则下次复盘的盲度声明失效。
|
||||
如果你不小心看到了,告诉我——我会在文件里补一个 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=true,cheat-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` 会持续提示
|
||||
@@ -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
|
||||
- YouTube:v= 参数后的 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 folder(fallback),但不询问稿子一致性,标 `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 篇(颜色 + 含义)
|
||||
按你的 cadence(X)= 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 adapter(manual-paste / youtube-data-api / 等)
|
||||
@@ -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)
|
||||
- rationale:ER+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 不进入 prediction,prediction 重新打)
|
||||
- 与 `/cheat-status` 协调:status 显示 "candidates 池有 N 条 tier1 未发",recommend 提供具体推荐
|
||||
@@ -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.md(Phase 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.XX,bucket=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.md(Phase 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")→ 跳过 6b,rubric-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 pass,retro 是触发器不是执行器
|
||||
|
||||
## Integration
|
||||
|
||||
- 前置:`/cheat-publish` 已登记 + 时间窗口达到
|
||||
- 下游:累计 `consecutive_directional_errors` 满 3 → 触发 `/cheat-bump` 提议
|
||||
- 状态字段更新:`calibration_samples` +1(这是 cheat-status 显示进度的关键)
|
||||
- pending_retros:剔除本条
|
||||
- 与 [observation-lifecycle.md](../../shared-references/observation-lifecycle.md) 紧耦合:每次复盘是观察新增的入口
|
||||
@@ -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 | 仍是 Claude,RLHF 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 公式:
|
||||
|
||||
- v0:7 维等权(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:默认 delegate;Phase 2.5 用 disagreement detection
|
||||
- **`cheat-bump`** Phase 2:**强制** delegate,bump 时**不接受 self-scored fallback**
|
||||
- **`cheat-retro`**:不调用——retro 本来就看实绩,blind 无意义
|
||||
@@ -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 次 → 控制台温和提示"反复打分会引入决策疲劳,差不多可以决定了"
|
||||
@@ -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 写作 tells(em-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. **直接在本文件改写**(同 path:scripts/<...>.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 tells(em-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.md(tier1,已 deep_read)
|
||||
- 下游:(默认)draft → Phase 4.5 humanizer 去 AI 味 → scripts/<id>.md → 用户改写 → /cheat-predict
|
||||
- 可选依赖:[`humanizer`](https://github.com/blader/humanizer) skill(MIT,外部项目)。装在 `~/.claude/skills/humanizer/` 时 Phase 4.5 自动启用;未装则优雅跳过。**不打包进 cheat-on-content**——用户自己 clone
|
||||
- 与 `/cheat-trends` 区别:cheat-seed 是**讨论 + 写 draft**(重 conversation);cheat-trends 是**多 adapter 抓 + 粗打分**(重 fetch)。两者目的不同,不互相替代。
|
||||
@@ -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 text(rapidfuzz 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 4:state 更新
|
||||
|
||||
```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 天没发)。
|
||||
@@ -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 check,cheat-seed 仍可读
|
||||
|
||||
### Phase 4: 输出看板
|
||||
|
||||
```
|
||||
🎛️ cheat-on-content 状态(更新于 2026-05-04 15:00)
|
||||
|
||||
内容形态:opinion-video / 时长 3-5min / cadence: 隔日更
|
||||
当前 rubric:v2 (上次 bump: 2026-04-22)
|
||||
校准样本:18 篇
|
||||
Confidence: 🟢 较高 (中枢 ±15%,rubric 形态稳定)
|
||||
Baseline: 4.2w 中位数
|
||||
|
||||
📦 Buffer:3 篇(🟢 绿色)
|
||||
按你的 cadence (隔日更)= 6 天 buffer,节奏稳定
|
||||
|
||||
📊 进度条
|
||||
[█████████████░░░░░] 18 / 30 → SQLite 升级建议门槛
|
||||
[██████████░░░░░░░░] 18 / 10 → percentile 桶可用(已超过门槛)
|
||||
|
||||
🎬 待办(按紧急度)
|
||||
🚨 复盘 1 篇(已过 T+3d)
|
||||
- predictions/2026-05-01_db063817_你已不在关系里.md(T+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.json,status 是这些更新的可视化
|
||||
- 下游:每个建议都路由到具体子 skill
|
||||
- meta-logging hook(如启用) → 写 usage.jsonl,status 用它算"距上次 X 多少次"
|
||||
@@ -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(默认,永远能用)
|
||||
- aihot(AI 热点聚合,无需 key)
|
||||
- weibo-hot(微博热搜,无需 key)
|
||||
- zhihu-hot(知乎热榜,无需 key)
|
||||
- trendradar-mcp(TrendRadar 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 走 IO;inline 复用打分逻辑)
|
||||
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-mcp(MCP 服务未配置)
|
||||
|
||||
去重后剩 27 条新 item。
|
||||
粗打分后 12 条 composite ≥ 6.0:
|
||||
|
||||
| # | 标题 | source | composite | bucket | rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 为什么我们都讨厌主动联系朋友 | aihot | 8.4 | 30-100w | ER+QL 双 5,AB 普适 |
|
||||
| 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 只产 candidates,predict 是另一个动作
|
||||
|
||||
## 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 实现)。
|
||||
@@ -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 就觉得这条会爆——别信。
|
||||
@@ -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 篇之后再开始相信数字。
|
||||
@@ -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 候选版本会用 TS(Topic 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/A(cold-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 后被替换为用户自己拟合的版本
|
||||
@@ -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 时,大胆重新定义维度。
|
||||
@@ -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 期你不是在做决策,你是在收集数据。** 播客迭代慢,更要珍惜每一期的校准价值——别跳过复盘。
|
||||
@@ -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 条你不是在做决策,你是在收集数据。** 短文的好处是迭代快——别浪费这个优势。
|
||||
@@ -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 篇你不是在做决策,你是在收集数据。** 教程的长尾特性意味着你需要更多耐心——给每篇足够的时间窗口再做复盘。
|
||||
@@ -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 条评论,主要变化:...`)
|
||||
@@ -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 不再读)
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,会麻木
|
||||
@@ -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');
|
||||
@@ -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/
|
||||
|
||||
# ── Python(adapter / 工具脚本跑过留下)──
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
venv/
|
||||
.python-version
|
||||
|
||||
# ── macOS / 编辑器 / 临时文件 ──
|
||||
.DS_Store
|
||||
._*
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.tmp
|
||||
*.log
|
||||
@@ -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
Reference in New Issue
Block a user