From 50bbe688098d27784f687e3da06e19de255cbeea Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:29:17 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .github/workflows/star-history.yml | 49 ++ .gitignore | 61 ++ CHANGELOG.md | 337 ++++++++ LICENSE | 21 + README.md | 181 +++++ README.wehub.md | 7 + SKILL.md | 225 ++++++ .../perf-data/bilibili-stat/.gitattributes | 2 + adapters/perf-data/bilibili-stat/README.md | 68 ++ adapters/perf-data/bilibili-stat/crawler.py | 135 ++++ adapters/perf-data/bilibili-stat/paths.py | 33 + adapters/perf-data/bilibili-stat/renderer.py | 93 +++ .../perf-data/bilibili-stat/requirements.txt | 1 + adapters/perf-data/bilibili-stat/review.py | 61 ++ adapters/perf-data/bilibili-stat/run.sh | 110 +++ adapters/perf-data/douyin-session/README.md | 122 +++ adapters/perf-data/douyin-session/crawler.py | 415 ++++++++++ adapters/perf-data/douyin-session/paths.py | 40 + adapters/perf-data/douyin-session/renderer.py | 100 +++ .../perf-data/douyin-session/requirements.txt | 1 + adapters/perf-data/douyin-session/review.py | 145 ++++ adapters/perf-data/douyin-session/run.sh | 134 ++++ .../perf-data/linkedin-session/.gitattributes | 2 + adapters/perf-data/linkedin-session/README.md | 151 ++++ .../perf-data/linkedin-session/crawler.py | 140 ++++ .../perf-data/linkedin-session/extract.py | 113 +++ adapters/perf-data/linkedin-session/paths.py | 41 + .../perf-data/linkedin-session/renderer.py | 105 +++ .../linkedin-session/requirements.txt | 1 + adapters/perf-data/linkedin-session/review.py | 69 ++ adapters/perf-data/linkedin-session/run.sh | 121 +++ .../linkedin-session/test_extract.py | 136 ++++ adapters/perf-data/xhs-explore/README.md | 152 ++++ adapters/perf-data/xhs-explore/crawler.py | 725 ++++++++++++++++++ adapters/perf-data/xhs-explore/paths.py | 40 + adapters/perf-data/xhs-explore/renderer.py | 142 ++++ .../perf-data/xhs-explore/requirements.txt | 2 + adapters/perf-data/xhs-explore/review.py | 412 ++++++++++ adapters/perf-data/xhs-explore/run.sh | 127 +++ adapters/script-extraction/whisper/README.md | 127 +++ adapters/script-extraction/whisper/run.sh | 133 ++++ adapters/trend-sources/aihot.md | 60 ++ adapters/trend-sources/trendradar-mcp.md | 66 ++ adapters/trend-sources/weibo-hot.md | 76 ++ adapters/trend-sources/zhihu-hot.md | 83 ++ docs/README_CN.md | 179 +++++ docs/guancha-no1.svg | 19 + docs/logo.svg | 53 ++ docs/star-history.svg | 31 + examples/script_patterns.example.md | 235 ++++++ hooks/log-event.sh | 62 ++ hooks/meta-logging.json | 53 ++ hooks/prediction-immutability.json | 18 + hooks/prediction-immutability.sh | 155 ++++ hooks/session-start.json | 18 + hooks/session-start.sh | 218 ++++++ install.sh | 274 +++++++ migrations/1.0-to-1.1.md | 99 +++ migrations/1.1-to-1.2.md | 66 ++ migrations/1.2-to-1.3.md | 80 ++ migrations/1.3-to-1.4.md | 150 ++++ migrations/registry.md | 67 ++ .../blind-prediction-protocol.md | 137 ++++ shared-references/bump-validation-protocol.md | 155 ++++ shared-references/cadence-protocol.md | 194 +++++ shared-references/candidate-schema.md | 169 ++++ shared-references/data-source-routing.md | 149 ++++ shared-references/migration-protocol.md | 149 ++++ shared-references/observation-lifecycle.md | 210 +++++ shared-references/prediction-anatomy.md | 332 ++++++++ shared-references/state-management.md | 346 +++++++++ skills/cheat-bump/SKILL.md | 477 ++++++++++++ skills/cheat-init/SKILL.md | 535 +++++++++++++ skills/cheat-learn-from/SKILL.md | 399 ++++++++++ skills/cheat-migrate/SKILL.md | 234 ++++++ skills/cheat-persona/SKILL.md | 179 +++++ skills/cheat-predict/SKILL.md | 452 +++++++++++ skills/cheat-publish/SKILL.md | 170 ++++ skills/cheat-recommend/SKILL.md | 235 ++++++ skills/cheat-retro/SKILL.md | 372 +++++++++ skills/cheat-score-blind/SKILL.md | 213 +++++ skills/cheat-score/SKILL.md | 159 ++++ skills/cheat-seed/SKILL.md | 432 +++++++++++ skills/cheat-shoot/SKILL.md | 213 +++++ skills/cheat-status/SKILL.md | 162 ++++ skills/cheat-trends/SKILL.md | 199 +++++ starter-rubrics/long-essay-zero.md | 136 ++++ starter-rubrics/opinion-video-zero.md | 234 ++++++ starter-rubrics/opinion-video.md | 195 +++++ starter-rubrics/other-zero.md | 149 ++++ starter-rubrics/podcast-zero.md | 145 ++++ starter-rubrics/short-text-zero.md | 145 ++++ starter-rubrics/tutorial-builder-zero.md | 147 ++++ templates/audience.template.md | 86 +++ templates/benchmark.template.md | 100 +++ templates/candidates.template.json | 56 ++ templates/candidates.template.md | 118 +++ templates/content.db.schema.sql | 181 +++++ templates/gitignore.template | 44 ++ templates/prediction.template.md | 137 ++++ templates/retro.template.md | 100 +++ templates/rubric-memo.template.md | 102 +++ templates/rubric_notes.template.md | 214 ++++++ templates/script_patterns.template.md | 201 +++++ templates/status.template.md | 48 ++ templates/workflow.template.md | 210 +++++ tools/diff_pct.py | 105 +++ tools/diff_pct_test.sh | 150 ++++ tools/gen-star-history.py | 166 ++++ tools/score-curve.py | 235 ++++++ tools/xkcd-font.css | 1 + uninstall.sh | 113 +++ 112 files changed, 16932 insertions(+) create mode 100644 .github/workflows/star-history.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 SKILL.md create mode 100644 adapters/perf-data/bilibili-stat/.gitattributes create mode 100644 adapters/perf-data/bilibili-stat/README.md create mode 100644 adapters/perf-data/bilibili-stat/crawler.py create mode 100644 adapters/perf-data/bilibili-stat/paths.py create mode 100644 adapters/perf-data/bilibili-stat/renderer.py create mode 100644 adapters/perf-data/bilibili-stat/requirements.txt create mode 100644 adapters/perf-data/bilibili-stat/review.py create mode 100755 adapters/perf-data/bilibili-stat/run.sh create mode 100644 adapters/perf-data/douyin-session/README.md create mode 100644 adapters/perf-data/douyin-session/crawler.py create mode 100644 adapters/perf-data/douyin-session/paths.py create mode 100644 adapters/perf-data/douyin-session/renderer.py create mode 100644 adapters/perf-data/douyin-session/requirements.txt create mode 100644 adapters/perf-data/douyin-session/review.py create mode 100755 adapters/perf-data/douyin-session/run.sh create mode 100644 adapters/perf-data/linkedin-session/.gitattributes create mode 100644 adapters/perf-data/linkedin-session/README.md create mode 100644 adapters/perf-data/linkedin-session/crawler.py create mode 100644 adapters/perf-data/linkedin-session/extract.py create mode 100644 adapters/perf-data/linkedin-session/paths.py create mode 100644 adapters/perf-data/linkedin-session/renderer.py create mode 100644 adapters/perf-data/linkedin-session/requirements.txt create mode 100644 adapters/perf-data/linkedin-session/review.py create mode 100755 adapters/perf-data/linkedin-session/run.sh create mode 100644 adapters/perf-data/linkedin-session/test_extract.py create mode 100644 adapters/perf-data/xhs-explore/README.md create mode 100644 adapters/perf-data/xhs-explore/crawler.py create mode 100644 adapters/perf-data/xhs-explore/paths.py create mode 100644 adapters/perf-data/xhs-explore/renderer.py create mode 100644 adapters/perf-data/xhs-explore/requirements.txt create mode 100644 adapters/perf-data/xhs-explore/review.py create mode 100644 adapters/perf-data/xhs-explore/run.sh create mode 100644 adapters/script-extraction/whisper/README.md create mode 100755 adapters/script-extraction/whisper/run.sh create mode 100644 adapters/trend-sources/aihot.md create mode 100644 adapters/trend-sources/trendradar-mcp.md create mode 100644 adapters/trend-sources/weibo-hot.md create mode 100644 adapters/trend-sources/zhihu-hot.md create mode 100644 docs/README_CN.md create mode 100644 docs/guancha-no1.svg create mode 100644 docs/logo.svg create mode 100644 docs/star-history.svg create mode 100644 examples/script_patterns.example.md create mode 100755 hooks/log-event.sh create mode 100644 hooks/meta-logging.json create mode 100644 hooks/prediction-immutability.json create mode 100755 hooks/prediction-immutability.sh create mode 100644 hooks/session-start.json create mode 100755 hooks/session-start.sh create mode 100755 install.sh create mode 100644 migrations/1.0-to-1.1.md create mode 100644 migrations/1.1-to-1.2.md create mode 100644 migrations/1.2-to-1.3.md create mode 100644 migrations/1.3-to-1.4.md create mode 100644 migrations/registry.md create mode 100644 shared-references/blind-prediction-protocol.md create mode 100644 shared-references/bump-validation-protocol.md create mode 100644 shared-references/cadence-protocol.md create mode 100644 shared-references/candidate-schema.md create mode 100644 shared-references/data-source-routing.md create mode 100644 shared-references/migration-protocol.md create mode 100644 shared-references/observation-lifecycle.md create mode 100644 shared-references/prediction-anatomy.md create mode 100644 shared-references/state-management.md create mode 100644 skills/cheat-bump/SKILL.md create mode 100644 skills/cheat-init/SKILL.md create mode 100644 skills/cheat-learn-from/SKILL.md create mode 100644 skills/cheat-migrate/SKILL.md create mode 100644 skills/cheat-persona/SKILL.md create mode 100644 skills/cheat-predict/SKILL.md create mode 100644 skills/cheat-publish/SKILL.md create mode 100644 skills/cheat-recommend/SKILL.md create mode 100644 skills/cheat-retro/SKILL.md create mode 100644 skills/cheat-score-blind/SKILL.md create mode 100644 skills/cheat-score/SKILL.md create mode 100644 skills/cheat-seed/SKILL.md create mode 100644 skills/cheat-shoot/SKILL.md create mode 100644 skills/cheat-status/SKILL.md create mode 100644 skills/cheat-trends/SKILL.md create mode 100644 starter-rubrics/long-essay-zero.md create mode 100644 starter-rubrics/opinion-video-zero.md create mode 100644 starter-rubrics/opinion-video.md create mode 100644 starter-rubrics/other-zero.md create mode 100644 starter-rubrics/podcast-zero.md create mode 100644 starter-rubrics/short-text-zero.md create mode 100644 starter-rubrics/tutorial-builder-zero.md create mode 100644 templates/audience.template.md create mode 100644 templates/benchmark.template.md create mode 100644 templates/candidates.template.json create mode 100644 templates/candidates.template.md create mode 100644 templates/content.db.schema.sql create mode 100644 templates/gitignore.template create mode 100644 templates/prediction.template.md create mode 100644 templates/retro.template.md create mode 100644 templates/rubric-memo.template.md create mode 100644 templates/rubric_notes.template.md create mode 100644 templates/script_patterns.template.md create mode 100644 templates/status.template.md create mode 100644 templates/workflow.template.md create mode 100755 tools/diff_pct.py create mode 100755 tools/diff_pct_test.sh create mode 100644 tools/gen-star-history.py create mode 100644 tools/score-curve.py create mode 100644 tools/xkcd-font.css create mode 100755 uninstall.sh diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml new file mode 100644 index 0000000..c7aad68 --- /dev/null +++ b/.github/workflows/star-history.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee9d8e1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fa93931 --- /dev/null +++ b/CHANGELOG.md @@ -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 [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/.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/.md` 行级 diff ≥ 30%(`V2_TRIGGER_THRESHOLD`)→ 自动调用 `/cheat-predict — mode: v2 — prediction-file: ` → 在原 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` 标记 + 完整版本链表 + - `-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 `**: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 模板用 `` 抽象占位,导致 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 你的整个 `/` 目录**。重大 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..35c7cb8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..75179e4 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +

+ Cheat on Content +

+ +

Cheat on Content

+ +

+ English +  ·  + 简体中文 +

+ +

+ + Watcha Hot List · 观猹热榜 · #1 + +

+ +

+Version +  +License +

+ +

+For content creators — a skill that turns every post into a calibrated experiment. +

+ +

+You're reading this. The skill predicted it.
+It turns every "I feel this will go viral" into a calibrated experiment.
+It took me from zero to 1M followers in a month. It said I'd write this. I did.
+Your doubt — predicted too. +

+ +--- + +## 🎬 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 + + + Star History Chart + + +--- + +## 📜 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.* diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..e45ad5e --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`XBuilderLAB/cheat-on-content` +- 原始仓库:https://github.com/XBuilderLAB/cheat-on-content +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..5aa7fcf --- /dev/null +++ b/SKILL.md @@ -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` 会创建缺失项;**绝不在没确认的情况下覆盖**。 + +``` +/ +├── .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__.md +├── predictions/ # immutable 预测日志(hook 保护) +│ └── YYYY-MM-DD__.md # 与 scripts/ 同 id +├── videos/ # 拍后才建(cheat-shoot 创建) +│ └── YYYY-MM-DD__/ +│ ├── script.md # 用户提供的最终拍摄稿(cheat-shoot 时询问"和 scripts/ 一致吗") +│ └── report.md # T+3d 抓的数据 + 评论(cheat-retro 写) +├── samples/ # 对标账号视频 / 转录(cheat-learn-from 创建) +│ └── <账号名>//{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 + 版本链表 +│ └── -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/
.md` +- 新增热点抓取源 → 加 `adapters/trend-sources/.md`,符合 [candidate-schema.md](shared-references/candidate-schema.md) 输出契约 +- 修改原则 → 改 `shared-references/.md`,所有引用它的 skill 自动跟进 +- 修改路由 → 改本文件的"路由表"段 +- 子 skill 内部细节 → 直接改对应 `skills/cheat-*/SKILL.md` + +完整开发指南见 README.md。 diff --git a/adapters/perf-data/bilibili-stat/.gitattributes b/adapters/perf-data/bilibili-stat/.gitattributes new file mode 100644 index 0000000..1bbd695 --- /dev/null +++ b/adapters/perf-data/bilibili-stat/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +*.py text eol=lf diff --git a/adapters/perf-data/bilibili-stat/README.md b/adapters/perf-data/bilibili-stat/README.md new file mode 100644 index 0000000..5777540 --- /dev/null +++ b/adapters/perf-data/bilibili-stat/README.md @@ -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 [] +``` + +- ``:`BV1cUoUY9Ecr`,或任意含 BV 号的链接(如 `https://www.bilibili.com/video/BV1cUoUY9Ecr`) +- ``:输出目录,`report.md` 会写进这里 +- ``:可选,原始稿子,会并入 report.md 供复盘 diff + +也可直接调底层: + +```bash +python review.py video BV1cUoUY9Ecr # 抓数据 → 写 videos//report.md +python review.py login # B站无需登录,仅打印说明(接口一致性) +``` + +## 输出(report.md) + +与 douyin-session 同格式,含: + +- 视频元信息(标题 / UP主 / 发布时间 / 时长 / 链接) +- **播放数据**:播放、点赞、投币、收藏、分享、评论、弹幕,并附派生比率(赞播比 / 投币率 / 收藏率 / 分播比)——B站「三连率」尤其能反映硬核认可度 +- 原始稿子(若提供) +- **热门评论**(按点赞降序,带 IP 属地) + +## 接口 + +| 数据 | 接口 | +|---|---| +| 视频统计 | `GET https://api.bilibili.com/x/web-interface/view?bvid=` | +| 评论 | `GET https://api.bilibili.com/x/v2/reply?type=1&oid=&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 即可。 diff --git a/adapters/perf-data/bilibili-stat/crawler.py b/adapters/perf-data/bilibili-stat/crawler.py new file mode 100644 index 0000000..4e781f5 --- /dev/null +++ b/adapters/perf-data/bilibili-stat/crawler.py @@ -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= +- 评论: https://api.bilibili.com/x/v2/reply?type=1&oid=&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 即可。") diff --git a/adapters/perf-data/bilibili-stat/paths.py b/adapters/perf-data/bilibili-stat/paths.py new file mode 100644 index 0000000..c3bddde --- /dev/null +++ b/adapters/perf-data/bilibili-stat/paths.py @@ -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" diff --git a/adapters/perf-data/bilibili-stat/renderer.py b/adapters/perf-data/bilibili-stat/renderer.py new file mode 100644 index 0000000..5585c0b --- /dev/null +++ b/adapters/perf-data/bilibili-stat/renderer.py @@ -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}" diff --git a/adapters/perf-data/bilibili-stat/requirements.txt b/adapters/perf-data/bilibili-stat/requirements.txt new file mode 100644 index 0000000..6ecf620 --- /dev/null +++ b/adapters/perf-data/bilibili-stat/requirements.txt @@ -0,0 +1 @@ +httpx>=0.27 diff --git a/adapters/perf-data/bilibili-stat/review.py b/adapters/perf-data/bilibili-stat/review.py new file mode 100644 index 0000000..a18d54c --- /dev/null +++ b/adapters/perf-data/bilibili-stat/review.py @@ -0,0 +1,61 @@ +"""发完视频后跑一次:抓 B站数据/评论 → 生成 NotebookLM 友好的 md。 + +用法: + python review.py login # B站无需登录(仅为接口一致) + python review.py video [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 [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() diff --git a/adapters/perf-data/bilibili-stat/run.sh b/adapters/perf-data/bilibili-stat/run.sh new file mode 100755 index 0000000..9192f8a --- /dev/null +++ b/adapters/perf-data/bilibili-stat/run.sh @@ -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 [] +# +# 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 []" >&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 </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 diff --git a/adapters/perf-data/douyin-session/README.md b/adapters/perf-data/douyin-session/README.md new file mode 100644 index 0000000..6d38d9b --- /dev/null +++ b/adapters/perf-data/douyin-session/README.md @@ -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 /script.md + +# 输出在 当前目录/videos/<日期>_/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**——不需要全装。 diff --git a/adapters/perf-data/douyin-session/crawler.py b/adapters/perf-data/douyin-session/crawler.py new file mode 100644 index 0000000..14d427c --- /dev/null +++ b/adapters/perf-data/douyin-session/crawler.py @@ -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()) diff --git a/adapters/perf-data/douyin-session/paths.py b/adapters/perf-data/douyin-session/paths.py new file mode 100644 index 0000000..c7ec16f --- /dev/null +++ b/adapters/perf-data/douyin-session/paths.py @@ -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" diff --git a/adapters/perf-data/douyin-session/renderer.py b/adapters/perf-data/douyin-session/renderer.py new file mode 100644 index 0000000..5a311f2 --- /dev/null +++ b/adapters/perf-data/douyin-session/renderer.py @@ -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}" diff --git a/adapters/perf-data/douyin-session/requirements.txt b/adapters/perf-data/douyin-session/requirements.txt new file mode 100644 index 0000000..86a8fae --- /dev/null +++ b/adapters/perf-data/douyin-session/requirements.txt @@ -0,0 +1 @@ +playwright>=1.44 diff --git a/adapters/perf-data/douyin-session/review.py b/adapters/perf-data/douyin-session/review.py new file mode 100644 index 0000000..cabb3d2 --- /dev/null +++ b/adapters/perf-data/douyin-session/review.py @@ -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() diff --git a/adapters/perf-data/douyin-session/run.sh b/adapters/perf-data/douyin-session/run.sh new file mode 100755 index 0000000..2a13dc0 --- /dev/null +++ b/adapters/perf-data/douyin-session/run.sh @@ -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 diff --git a/adapters/perf-data/linkedin-session/.gitattributes b/adapters/perf-data/linkedin-session/.gitattributes new file mode 100644 index 0000000..1bbd695 --- /dev/null +++ b/adapters/perf-data/linkedin-session/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +*.py text eol=lf diff --git a/adapters/perf-data/linkedin-session/README.md b/adapters/perf-data/linkedin-session/README.md new file mode 100644 index 0000000..d5a80d9 --- /dev/null +++ b/adapters/perf-data/linkedin-session/README.md @@ -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**——不需要全装。 diff --git a/adapters/perf-data/linkedin-session/crawler.py b/adapters/perf-data/linkedin-session/crawler.py new file mode 100644 index 0000000..1347fbd --- /dev/null +++ b/adapters/perf-data/linkedin-session/crawler.py @@ -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()) diff --git a/adapters/perf-data/linkedin-session/extract.py b/adapters/perf-data/linkedin-session/extract.py new file mode 100644 index 0000000..a26d2fd --- /dev/null +++ b/adapters/perf-data/linkedin-session/extract.py @@ -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()} diff --git a/adapters/perf-data/linkedin-session/paths.py b/adapters/perf-data/linkedin-session/paths.py new file mode 100644 index 0000000..dfe5a38 --- /dev/null +++ b/adapters/perf-data/linkedin-session/paths.py @@ -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" diff --git a/adapters/perf-data/linkedin-session/renderer.py b/adapters/perf-data/linkedin-session/renderer.py new file mode 100644 index 0000000..471c417 --- /dev/null +++ b/adapters/perf-data/linkedin-session/renderer.py @@ -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("_") diff --git a/adapters/perf-data/linkedin-session/requirements.txt b/adapters/perf-data/linkedin-session/requirements.txt new file mode 100644 index 0000000..86a8fae --- /dev/null +++ b/adapters/perf-data/linkedin-session/requirements.txt @@ -0,0 +1 @@ +playwright>=1.44 diff --git a/adapters/perf-data/linkedin-session/review.py b/adapters/perf-data/linkedin-session/review.py new file mode 100644 index 0000000..c8acec2 --- /dev/null +++ b/adapters/perf-data/linkedin-session/review.py @@ -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() diff --git a/adapters/perf-data/linkedin-session/run.sh b/adapters/perf-data/linkedin-session/run.sh new file mode 100755 index 0000000..b787099 --- /dev/null +++ b/adapters/perf-data/linkedin-session/run.sh @@ -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 diff --git a/adapters/perf-data/linkedin-session/test_extract.py b/adapters/perf-data/linkedin-session/test_extract.py new file mode 100644 index 0000000..40722ee --- /dev/null +++ b/adapters/perf-data/linkedin-session/test_extract.py @@ -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") diff --git a/adapters/perf-data/xhs-explore/README.md b/adapters/perf-data/xhs-explore/README.md new file mode 100644 index 0000000..7a46c71 --- /dev/null +++ b/adapters/perf-data/xhs-explore/README.md @@ -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**。 diff --git a/adapters/perf-data/xhs-explore/crawler.py b/adapters/perf-data/xhs-explore/crawler.py new file mode 100644 index 0000000..370e1a0 --- /dev/null +++ b/adapters/perf-data/xhs-explore/crawler.py @@ -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()) diff --git a/adapters/perf-data/xhs-explore/paths.py b/adapters/perf-data/xhs-explore/paths.py new file mode 100644 index 0000000..346edb9 --- /dev/null +++ b/adapters/perf-data/xhs-explore/paths.py @@ -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" diff --git a/adapters/perf-data/xhs-explore/renderer.py b/adapters/perf-data/xhs-explore/renderer.py new file mode 100644 index 0000000..57d9839 --- /dev/null +++ b/adapters/perf-data/xhs-explore/renderer.py @@ -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"![图片{i}]({img})") + lines.append("") + + # galaxy 原始 JSON——首跑时用来确认曝光字段的真实 key 名 + raw = note.get("raw") + if raw: + lines.append("### galaxy 原始字段(首跑校准用,确认曝光字段 key 后可忽略)") + lines.append("") + lines.append("```json") + full = json.dumps(raw, ensure_ascii=False, indent=2) + truncated = full[:2500] + if len(full) > 2500: + truncated += "\n... (truncated)" + lines.append(truncated) + lines.append("```") + lines.append("") + + lines.append("## 原始稿子") + lines.append("") + lines.append(script.strip() if script.strip() else "(未提供)") + lines.append("") + + lines.append(f"## 评论(按点赞降序,共 {len(comments)} 条)") + lines.append("") + if not comments: + lines.append("(未抓到评论——可能 xsec_token 缺失、评论被关或需手动粘贴)") + else: + for c in comments: + reply = f" 💬{c['sub_comment_count']}" if c.get("sub_comment_count") else "" + ip = f" [{c['ip_label']}]" if c.get("ip_label") else "" + text = (c.get("text") or "").replace("\n", " ").strip() + lines.append(f"- [👍{c['like_count']}{reply}]{ip} {text}") + lines.append("") + + return "\n".join(lines) + + +def slugify(text: str, max_len: int = 30) -> str: + bad = '<>:"/\\|?*\n\r\t' + out = "".join("_" if ch in bad else ch for ch in text).strip() + return out[:max_len] or "untitled" + + +def output_dir_for(note: dict, root: Path) -> Path: + date = (note.get("post_time_str") or _fmt_time(note.get("create_time", 0)))[:10].replace("未知", "nodate") + slug = slugify(note.get("title") or note["note_id"]) + return root / f"{date}_{slug}" diff --git a/adapters/perf-data/xhs-explore/requirements.txt b/adapters/perf-data/xhs-explore/requirements.txt new file mode 100644 index 0000000..99fd92d --- /dev/null +++ b/adapters/perf-data/xhs-explore/requirements.txt @@ -0,0 +1,2 @@ +playwright>=1.44 +requests>=2.28 diff --git a/adapters/perf-data/xhs-explore/review.py b/adapters/perf-data/xhs-explore/review.py new file mode 100644 index 0000000..e4ee5da --- /dev/null +++ b/adapters/perf-data/xhs-explore/review.py @@ -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() diff --git a/adapters/perf-data/xhs-explore/run.sh b/adapters/perf-data/xhs-explore/run.sh new file mode 100644 index 0000000..efd5b86 --- /dev/null +++ b/adapters/perf-data/xhs-explore/run.sh @@ -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 diff --git a/adapters/script-extraction/whisper/README.md b/adapters/script-extraction/whisper/README.md new file mode 100644 index 0000000..081ec44 --- /dev/null +++ b/adapters/script-extraction/whisper/README.md @@ -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 会自动找到。 diff --git a/adapters/script-extraction/whisper/run.sh b/adapters/script-extraction/whisper/run.sh new file mode 100755 index 0000000..3c45a37 --- /dev/null +++ b/adapters/script-extraction/whisper/run.sh @@ -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 diff --git a/adapters/trend-sources/aihot.md b/adapters/trend-sources/aihot.md new file mode 100644 index 0000000..7a2717d --- /dev/null +++ b/adapters/trend-sources/aihot.md @@ -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。 diff --git a/adapters/trend-sources/trendradar-mcp.md b/adapters/trend-sources/trendradar-mcp.md new file mode 100644 index 0000000..274158a --- /dev/null +++ b/adapters/trend-sources/trendradar-mcp.md @@ -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)。 diff --git a/adapters/trend-sources/weibo-hot.md b/adapters/trend-sources/weibo-hot.md new file mode 100644 index 0000000..96b4e2d --- /dev/null +++ b/adapters/trend-sources/weibo-hot.md @@ -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(待)— 新榜 / 飞瓜,付费但稳定 diff --git a/adapters/trend-sources/zhihu-hot.md b/adapters/trend-sources/zhihu-hot.md new file mode 100644 index 0000000..95925af --- /dev/null +++ b/adapters/trend-sources/zhihu-hot.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(待)— 付费稳定数据源 diff --git a/docs/README_CN.md b/docs/README_CN.md new file mode 100644 index 0000000..f3d3c15 --- /dev/null +++ b/docs/README_CN.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 也是。* +*未来从不奖励努力——它只奖励先看见规律的人。* + +*你看到这一行——也是它预测的。* diff --git a/docs/guancha-no1.svg b/docs/guancha-no1.svg new file mode 100644 index 0000000..69eb087 --- /dev/null +++ b/docs/guancha-no1.svg @@ -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 · 热榜第一 + + + + + + + + + 1 + + + Watcha Hot List · 观猹热榜 + + + #1 Trending · 热榜第一 + diff --git a/docs/logo.svg b/docs/logo.svg new file mode 100644 index 0000000..651d222 --- /dev/null +++ b/docs/logo.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + — fatesnail · zsh — + + + $ fatesnail + + + + + + › predict ▸ post ▸ recon ▸ evolve + + + › rubric.v0.42 — calibrated for you + + + // cheat on content + + + + diff --git a/docs/star-history.svg b/docs/star-history.svg new file mode 100644 index 0000000..d747d76 --- /dev/null +++ b/docs/star-history.svg @@ -0,0 +1,31 @@ + + + +Star History + +0 + +1K + +2K + +3K + +4K + +5K + +6K +Jun +Jul + + +Date +GitHub Stars + + + + +XBuilderLAB/cheat-on-content +star-history.com + diff --git a/examples/script_patterns.example.md b/examples/script_patterns.example.md new file mode 100644 index 0000000..2e1bdac --- /dev/null +++ b/examples/script_patterns.example.md @@ -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") diff --git a/hooks/log-event.sh b/hooks/log-event.sh new file mode 100755 index 0000000..6b18029 --- /dev/null +++ b/hooks/log-event.sh @@ -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 +# ∈ {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 diff --git a/hooks/meta-logging.json b/hooks/meta-logging.json new file mode 100644 index 0000000..3e72ca5 --- /dev/null +++ b/hooks/meta-logging.json @@ -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 + } + ] + } + ] + } +} diff --git a/hooks/prediction-immutability.json b/hooks/prediction-immutability.json new file mode 100644 index 0000000..aa9be17 --- /dev/null +++ b/hooks/prediction-immutability.json @@ -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 + } + ] + } + ] + } +} diff --git a/hooks/prediction-immutability.sh b/hooks/prediction-immutability.sh new file mode 100755 index 0000000..c5950eb --- /dev/null +++ b/hooks/prediction-immutability.sh @@ -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 <&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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4fdf3f3 --- /dev/null +++ b/install.sh @@ -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 +# # 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 " + 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 "" diff --git a/migrations/1.0-to-1.1.md b/migrations/1.0-to-1.1.md new file mode 100644 index 0000000..c561f5d --- /dev/null +++ b/migrations/1.0-to-1.1.md @@ -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 正常读。 diff --git a/migrations/1.1-to-1.2.md b/migrations/1.1-to-1.2.md new file mode 100644 index 0000000..9400baf --- /dev/null +++ b/migrations/1.1-to-1.2.md @@ -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//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' ` → 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 触发时按需补全。 diff --git a/migrations/1.2-to-1.3.md b/migrations/1.2-to-1.3.md new file mode 100644 index 0000000..166d55f --- /dev/null +++ b/migrations/1.2-to-1.3.md @@ -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 diff --git a/migrations/1.3-to-1.4.md b/migrations/1.3-to-1.4.md new file mode 100644 index 0000000..778aadb --- /dev/null +++ b/migrations/1.3-to-1.4.md @@ -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/.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 +``` diff --git a/migrations/registry.md b/migrations/registry.md new file mode 100644 index 0000000..654d6de --- /dev/null +++ b/migrations/registry.md @@ -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 | + +--- + +## 迁移文件命名约定 + +- 文件名:`-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/-to-.md`,4 段都填 +6. CHANGELOG.md 标 `BREAKING`(major)或 `MINOR`,加迁移文件链接 +7. 跑一遍:在 fixture 老 state 上调 `/cheat-migrate`,验证升级到新版且 state 字段齐全 + +如果懒得写 4 段的标准 migration 文件,把 schema bump **推迟**到下一次有规模改动时一起做——不要先 bump schema 再补文档。 diff --git a/shared-references/blind-prediction-protocol.md b/shared-references/blind-prediction-protocol.md new file mode 100644 index 0000000..9f4dcc8 --- /dev/null +++ b/shared-references/blind-prediction-protocol.md @@ -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%,于 发现笔误` +- "我前面没考虑 SR=4,重打一下分" → 拒绝。同上路径 + +唯一允许编辑预测段的场景:**纯 markdown 排版错误**(标题层级错误、列表 bullet 格式错),且用户明确说明这是格式修复。这种情况 hook 仍会阻塞,需要用户显式 bypass(手动设置环境变量 `CHEAT_BYPASS_IMMUTABILITY=1` 单次)——bypass 应在 git history 留痕。 + +--- + +## 文件名约定(**三处一致**) + +一个内容三处文件,**用同一组 `__` 命名**: + +``` +scripts/__.md ← pre-shoot 草稿(cheat-seed 写或用户写) +predictions/__.md ← immutable 预测(cheat-predict 写) +videos/__/ ← 拍后才建(cheat-shoot 创建) + ├── script.md ← 用户提供的最终拍摄稿 + └── report.md ← T+3d 数据(cheat-retro 写) +``` + +- ``:**草稿首次落盘日期**(即 `scripts/.md` 的创建日),不是预测日 / 拍摄日 / 发布日。理由:保持 ID 稳定——草稿大改后 hash 变了仍想保持文件可追溯 +- ``:12 位 sha256 前缀,对**草稿首次落盘的内容**做 hash。用户 edit 草稿后**不变**——便于跨文件引用 +- ``:3-8 字中文或英文短名,便于人类辨识 + +Reconstructed 重做:在 `` 后加 `_redo`,三处都加: +- `scripts/___redo.md` +- `predictions/___redo.md` +- `videos/___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**: 预测段于 被外部修改,无法保证盲度`,校准价值降级为"参考",不计入 bump 校准池 | +| 预测文件遗失 / 被删 | git log 找回。找不到 → 在 `rubric_notes.md` 记录" 预测文件遗失,校准池缺该样本" | +| 用户原本是 cold-start,半路想"补"已发作品的预测 | 一律记为 `**Reconstructed retrospective**`,不计入校准池——这是补的不是预测。可作为"观察"记录到 `rubric_notes.md` | + +--- + +## 反模式(必须拒绝的请求) + +- 「帮我预测一下,但我先告诉你播放量你来反推就行」 → 拒绝。直接破坏盲度 +- 「这条已经发了 5 天数据出来了,但你假装没看到,给我做个预测看会不会准」 → 拒绝。请改用 `_redo.md` 走 reconstructed 路径 +- 「上次预测算错了,帮我把概率分布改一下」 → 拒绝。在复盘段说明 +- 「能不能跳过 blind check 我有特殊原因」 → 询问原因;只有"格式修复"是合法的 bypass 理由 + +--- + +## Why(为什么这套这么严) + +盲预测是整个 cheat-on-content 校准循环的**唯一信号源**。一旦预测段被事后修改,所有"哪个维度被验证 / 推翻"的判断都失去基线——你不知道当初是真预测对了,还是事后改对了。 + +校准价值 = 预测精度 × 预测可信度。 +- 预测精度可以靠 rubric 升级慢慢提升。 +- 预测可信度一旦破坏不可恢复——**这是为什么 immutability 是 hook 层强制,不是君子协定**。 diff --git a/shared-references/bump-validation-protocol.md b/shared-references/bump-validation-protocol.md new file mode 100644 index 0000000..c38f0e3 --- /dev/null +++ b/shared-references/bump-validation-protocol.md @@ -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 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,需要单独走流程 diff --git a/shared-references/cadence-protocol.md b/shared-references/cadence-protocol.md new file mode 100644 index 0000000..9ca4428 --- /dev/null +++ b/shared-references/cadence-protocol.md @@ -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 输出 | diff --git a/shared-references/candidate-schema.md b/shared-references/candidate-schema.md new file mode 100644 index 0000000..be0a25a --- /dev/null +++ b/shared-references/candidate-schema.md @@ -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 | 来源标识,格式 `:`,例:`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 写)。 diff --git a/shared-references/data-source-routing.md b/shared-references/data-source-routing.md new file mode 100644 index 0000000..063051d --- /dev/null +++ b/shared-references/data-source-routing.md @@ -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/.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 模型可扩展。 diff --git a/shared-references/migration-protocol.md b/shared-references/migration-protocol.md new file mode 100644 index 0000000..64e98a8 --- /dev/null +++ b/shared-references/migration-protocol.md @@ -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/-to-.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-`。 + +备份保留多久: +- 用户跑 `/cheat-status` 时,如果有备份 + state 已稳定运行 N 天 → 提示"可以清理 N 个旧备份" +- `/cheat-init` 重 init 时清理所有旧备份(既然要重 init,老备份意义不大) +- 用户手动 `rm .cheat-state.json.backup-*` 永远 OK + +不入版本控制:`.cheat-state.json.backup-*` 应在 `.gitignore` 里(已含 `.cheat-state.json` 通配规则)。 diff --git a/shared-references/observation-lifecycle.md b/shared-references/observation-lifecycle.md new file mode 100644 index 0000000..d230000 --- /dev/null +++ b/shared-references/observation-lifecycle.md @@ -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/.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 秒内理解当下打分规则 diff --git a/shared-references/prediction-anatomy.md b/shared-references/prediction-anatomy.md new file mode 100644 index 0000000..06b9a1d --- /dev/null +++ b/shared-references/prediction-anatomy.md @@ -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/.md initial content, 取前 12) +**Title**: <作品完整标题> +**Rubric Version**: **v0** | **v1** | **v2** | ... +**预测时间**: 2026-05-04(基于最终稿) +**Script Path**: scripts/2026-05-04__.md +**Script Hash**: +**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**: +**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 `——只在 delta ≥ DISAGREEMENT_THRESHOLD 时出现 + +字段必填规则: +- `Rubric Version` 必填——将来 v3 时代回看 v2 预测,没有版本号就无法公平对比 +- `预测时数据状态` 必填——明确声明 blind 是 immutable 承诺的前提 +- `Script Path` 必填——指向 `scripts/.md`(pre-shoot 草稿) +- `Script Hash` 必填——cheat-shoot 时再 hash `videos//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% +- **`` → 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% 预期): +- [基准线验证什么] + +**如果跌到 ` 谁问你了(比率 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__.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/.md` 的 line-diff ≥ 30%([V2_TRIGGER_THRESHOLD](../skills/cheat-shoot/SKILL.md)),调用 `/cheat-predict — mode: v2 — prediction-file: ` +- **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//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 | diff --git a/shared-references/state-management.md b/shared-references/state-management.md new file mode 100644 index 0000000..f36c533 --- /dev/null +++ b/shared-references/state-management.md @@ -0,0 +1,346 @@ +# State Management(状态文件读写约定) + +被所有子 skill 引用。`.cheat-state.json` 是各子 skill 共享上下文的**单一来源**——任何运行时状态、累计指标、模式标记都从这里读、写回这里。 + +--- + +## 文件位置 + +``` +/.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/-to-.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 等级。这是新设计的核心原则。 diff --git a/skills/cheat-bump/SKILL.md b/skills/cheat-bump/SKILL.md new file mode 100644 index 0000000..a7274b0 --- /dev/null +++ b/skills/cheat-bump/SKILL.md @@ -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/.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: + rubric_notes_path: rubric_notes.md + sidecar_path: .cheat-cache/bump-rescores/.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` 行 + +#### 还污染没污染的诚实标注 + +即使走 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**: ` + - `**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": "", + "last_bump_self_audited": false, + "consecutive_directional_errors": [], + "calibration_samples_at_last_bump": +} +``` + +清空 `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 打分 diff --git a/skills/cheat-init/SKILL.md b/skills/cheat-init/SKILL.md new file mode 100644 index 0000000..42a0e62 --- /dev/null +++ b/skills/cheat-init/SKILL.md @@ -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//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` → `/.gitignore` + - 如 `/.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": , + "target_publish_cadence_days": , + "rubric_form_mismatch": , + "benchmark_status": "", + "benchmark_name": , + "benchmark_sample_count": , + "baseline_plays": null, + "calibration_samples": , + "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": , + "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/-zero.md`(cold-start)或 `.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` → `/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 创建子目录) + + 前三处用同一组 __ 命名相互关联。 + samples/ 按对标账号名分组:samples/<账号名>//。" + ``` + +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` → `/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` → `/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//README.md](../../adapters/perf-data/))。 + +抓取成功后,对每条已发视频: + +1. **建 video folder**:`videos/__/` + - `` = 视频实际发布日 + - `` = 12 位 hash,对 (title + 平台 ID) 做 sha256 + - `` = 标题前 3-8 字 +2. **写 report.md**:从 adapter 抓回的数据(播放 / 点赞 / 评论 / 转发 / top 评论)填入 +3. **询问用户原稿**:"video 「{标题}」 你保留了原稿吗?" + - 是 → 用户提供 → 存为 `videos//script.md` + - 否 → 标 `script_lost`(仍建 video folder,只是 script.md 缺失) +4. **写 reconstructed prediction**:`predictions/__.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: ,confidence: ) + +下次你可以直接说这些: + +📝 写完一篇稿子 → "打分这篇 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`** | diff --git a/skills/cheat-learn-from/SKILL.md b/skills/cheat-learn-from/SKILL.md new file mode 100644 index 0000000..4be4a35 --- /dev/null +++ b/skills/cheat-learn-from/SKILL.md @@ -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 ` → 用新 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/<账号名>/