SKILL DETAIL
dbs-content-system
dontbesilent2025/dbskill/dbs-content-system
dbs-content-system is a content structuring system designed to transform a user's locally accumulated content assets (such as drafts, tweets, WeChat articles, topic drafts, case materials, course scripts, etc.) from scattered inventory into a reusable, traceable, recomposable, and continuously growing structured engineering project. It helps users systematically manage content assets through steps like auditing, establishing an engineering skeleton, copying raw materials, extracting content units, building topic maps, and assembling topic drafts. The system emphasizes auditing before building, and the default goal is not to process everything at once but to reach a usable state. It defines five types of content units (Question, Concept, Opinion, Case, Solution) and four relationship types (Responds, Explains, Proves, Conflicts), along with deduplication rules and tooling scripts. Users only proceed to formal engineering when they meet thresholds for quantity, source diversity, and boundary clarity.
Installation
npx skills add https://github.com/dontbesilent2025/dbskill --skill dbs-content-system
스킬 파일
SKILL.md
최근 동기화 · 2026. 8. 29.
agents/openai.yaml›
interface:
display_name: "dbs-content-system"
short_description: "审计本地内容资产并建立结构化工程,生成主题地图、内容单元与装配稿"
default_prompt: "使用 $dbs-content-system,审计当前内容资产并建立可持续维护的结构化工程。"
docs/acceptance.md›
# dbs-content-system Acceptance
## 什么叫「系统能用了」
首次交付不要求全量结构化完成。
首次交付的最低标准是:
- 工程骨架已建立
- 根级 `AGENTS.md / CLAUDE.md / README.md / SOURCE_OF_TRUTH.md` 已存在
- 规则文件、模板文件、脚本文件已写入
- 原始素材已复制到 `01-原始素材区/完整副本/`
- 来源候选、原始索引、待处理清单已生成
- 首批样本已经抽成内容单元
- 已出现主题地图
- 已出现选题装配稿
- 已生成关系索引
- 已生成去重候选
- 已生成处理状态总览
- 已能先分类来源,再按来源类型决定是否归一化、抽取或跳过
## 最低数量门槛
样本模式下,默认至少看到:
- `15` 个内容单元
- `1` 张主题地图
- `2` 份以上装配稿
如果不足,继续补样本,不进入批量模式。
## 关键核验点
### 单元可复用
抽取后的内容单元不能只是标题、关键词或占位符回填。
至少要满足:
- `QST.question_text` 不能等于原文标题
- `OPI.core_claim` 不能只是关键词列表或数据标签
- `CON.concept_definition` 必须对准真实核心概念,不能抽到无关段落
- `CAS.case_summary / case_process / case_result` 不能同时停留在占位状态
- `SOL.action_steps` 不能继续是 `待人工补全步骤 1`
- 主问题、主观点、主方案的 `title` 不能继续停留在「某篇文稿的核心问题 / 三步方案」这类文件名兜底
- 编号短稿不能再批量生成 `001 的核心问题`、`002 的三步方案` 这类编号占位节点
验收方式:
- 随机抽查至少 `3` 份高价值单元
- 确认单元本身在不回看原文的情况下,已经能支撑后续装配
- 抽查至少 `2` 份主问题单元,确认 `question_type`、`核心内容` 和 `拆解边界` 与文稿类型匹配,不是旧规则串台
### 来源追溯
抽取后的内容单元,`source_documents` 不能继续停留在 `SRC-*` 占位符,必须写入真实来源 ID。
### 分类有效
- `README`、`CSV`、`JSON`、批处理 `cleaned / analysis report / content library` 这类中间产物不能再直接进入内容单元库
- 推文合集必须先切块,再进入抽取
- 短视频编号文稿不能继续直接用文件名当主题名和主问题名
- 跳过项、待人工复核项必须写入 `03-处理状态/已处理清单.csv`
### 关系有效
运行:
```bash
node 07-脚本与工具/generate-link-map.js
```
之后:
- `03-处理状态/关系索引.csv` 必须存在
- `03-处理状态/关系总览.md` 必须存在
- 目标缺失数必须可见
### 去重可审计
运行:
```bash
node 07-脚本与工具/generate-duplicate-candidates.js
```
之后:
- `03-处理状态/去重候选索引.csv` 必须存在
- `03-处理状态/去重与冲突总览.md` 必须存在
- `03-处理状态/去重与冲突审计.csv` 必须存在
### Obsidian 可见
运行:
```bash
node 07-脚本与工具/fill-obsidian-links.js
```
之后:
- 内容单元正文 `## 关联单元` 里应补入 `[[文件名]]`
- 主题地图与装配稿可以在 `Obsidian` 里直接点开
### 装配不是空壳
- 主题地图 `主题定义` 不能继续停留在 `待人工补全`
- 选题装配稿 `目标受众 / 装配理由 / 建议结构 / 表达骨架` 不能全部为空
- 装配稿应能说明这篇内容为什么值得这样组,而不是只列 5 个单元文件名
- 同一主题下应能看到补充问题、补充观点、补充案例、补充方案,证明它不是一次性装配
### 自动推荐可以重组
- 运行 `assemble-topic-from-units.js --auto` 后,不能只回到原主题内部的同类单元
- 对宽题,自动推荐出的主问题、主观点、主方案至少有一项应体现跨主题重组
- 例如「年轻人怎么赚钱」这类题,允许主问题来自「兴趣变现」,但主观点应能切到「需求倒推 / 老板思维」主轴,而不是永远停在兴趣变现的原始判断里
- 自动推荐产出的装配稿,至少要让人看出:系统不是按文件名抓 5 个点,而是在按主题关系重新组织表达
### 样本覆盖面
首批正式验收,不能只验证单一文稿类型。
至少覆盖:
- 「兴趣变现 / 生产型兴趣」类
- 「找生意 / 反赛道思维」类
- 「稳定收入 / 反脆弱」类
- 「系统赚钱 / 被动收入」类
- 「需求倒推 / 老板思维」类
如果某一类仍然只能生成通用兜底标题、空泛摘要或待补字段,则不能宣布通过。
## 验收命令
```bash
node 07-脚本与工具/generate-source-registry.js
node 07-脚本与工具/rebuild-processing-ledger.js
node 07-脚本与工具/extract-sample-units.js --help
node 07-脚本与工具/generate-link-map.js
node 07-脚本与工具/generate-duplicate-candidates.js
node 07-脚本与工具/fill-obsidian-links.js
node 07-脚本与工具/summarize-system.js
node 07-脚本与工具/assemble-topic-from-units.js --title '年轻人怎么赚钱' --auto --top 3
```
## 当前正式版证据
仓库内已有一份从空目录跑通的验收样本:
`测试 dbs-content-system/正式版验收`
docs/quickstart.md›
# dbs-content-system Quickstart
## 定位
`dbs-content-system` 是 `dbskill` 里的进阶内容工程模块。
它适合这类用户:
- 本地已经积累了大量文稿、推文、课程稿、案例素材
- 不只是想改一篇内容,而是想把旧内容变成可复用资产
- 愿意先做样本验证,再逐步推进批量结构化
它不适合这类用户:
- 只有零散素材
- 只想优化单篇内容
- 还没有明确边界就想一口气全量处理
## 安装
整套安装:
```bash
npx -y skills add dontbesilent2025/dbskill -g --all
```
单独安装本模块:
```bash
npx -y skills add dontbesilent2025/dbskill --skill dbs-content-system
```
## 最短启动链路
### 1. 初始化新工程
```bash
node tools/init-content-system.js /你的/新工程目录
```
### 2. 复制首批样本文稿
把 `3` 到 `5` 篇代表性文稿复制到:
`01-原始素材区/完整副本/`
优先级:
- 已经有清晰标题、分段、小标题的 `Markdown`
- 已经带有「核心观点提炼」「概念定义」「适用场景」之类区块的主稿
- 不要先拿导出版 `HTML` 做首批验证
### 3. 生成来源候选与原始索引
```bash
cd /你的/新工程目录
node 07-脚本与工具/generate-source-registry.js
node 07-脚本与工具/rebuild-processing-ledger.js
```
### 4. 自动抽取首批样本
```bash
node 07-脚本与工具/extract-sample-units.js --files '完整副本/路径1.md,完整副本/路径2.md,完整副本/路径3.md'
```
首批验证不要只挑同一种稿子。
至少覆盖:
- 1 篇「兴趣变现 / 生产型兴趣」类文稿
- 1 篇「找生意 / 反赛道思维」类文稿
- 1 篇「稳定收入 / 反脆弱」类文稿
- 1 篇「系统赚钱 / 被动收入」类文稿
- 1 篇「需求倒推 / 老板思维」类文稿
这样才能看出抽取器是不是只对单一题材成立。
### 5. 跑校验链路
```bash
node 07-脚本与工具/generate-link-map.js
node 07-脚本与工具/generate-duplicate-candidates.js
node 07-脚本与工具/fill-obsidian-links.js
node 07-脚本与工具/summarize-system.js
```
### 6. 用真实单元重组一个新选题
手工指定单元:
```bash
node 07-脚本与工具/assemble-topic-from-units.js \
--title '年轻人怎么赚钱(结构化重组版)' \
--question 'QST-20260602-192,QST-20260602-199' \
--concept 'CON-20260602-190,CON-20260602-194' \
--opinion 'OPI-20260602-200,OPI-20260602-198' \
--case 'CAS-20260602-192,CAS-20260602-199' \
--solution 'SOL-20260602-176,SOL-20260602-186'
```
按选题标题自动推荐:
```bash
node 07-脚本与工具/assemble-topic-from-units.js \
--title '年轻人怎么赚钱' \
--auto \
--top 3
```
说明:
- `--auto` 会按标题自动推荐每类单元的候选组合
- `--auto` 现在会优先尝试给宽题选出一条主轴,再补充跨主题单元
- 对于「年轻人怎么赚钱」这类宽题,主问题可以来自「兴趣变现」,主观点可以来自「需求倒推」,这正是系统要实现的重组能力
- 自动推荐结果已经可以作为第一版装配稿,但正式对外输出前仍要人工复核
## 跑完之后应该看到什么
- `02-内容单元库/` 里出现第一批内容单元
- `05-主题地图/` 里出现主题地图
- `06-选题装配/` 里出现装配稿
- `03-处理状态/` 里出现来源候选、原始索引、待处理清单、关系索引、去重候选和状态总览
还要额外检查:
- 内容单元不是标题回填
- 观点、概念、案例、方案字段已经能脱离原文被调用
- 主题地图和装配稿不是空壳
- 主题地图已经变成主题入口,而不是只列 5 个文件
- 装配稿已经写出「目标受众 / 装配理由 / 建议结构 / 表达骨架」
- 至少抽查 1 份装配稿,确认不回原文也能看懂为什么这样组
- 至少跑 1 次 `assemble-topic-from-units.js`,确认系统已经能从现有单元重组新选题
- 至少跑 1 次 `assemble-topic-from-units.js --auto`,确认自动推荐不是只会回到原主题内部,而是能拉出跨主题的主观点或补充单元
## 先看哪里
新工程初始化后,优先阅读:
1. `README.md`
2. `SOURCE_OF_TRUTH.md`
3. `03-处理状态/处理状态总览.md`
scaffold/root/AGENTS.md›
# 内容结构化系统
## 语言与排版
- 始终使用中文沟通
- 遵循《中文文案排版指北》:中英文之间加空格,中文与数字之间加空格,中文标点使用全角,数字使用半角,专有名词大小写正确
- 写作、改写、总结、输出文案时,中文引号默认使用全角直角引号「」
## 系统定位
本工程用于管理本地内容相关资产。
核心目标不是归档文稿,而是把原始文稿加工为可复用、可追溯、可重组的「内容单元」,并支持后续按选题装配为文章、视频稿、标题方案与系列内容。
## 目录职责
- `00-规则与索引/`:系统规则、字段规范、关系规则、去重规则、模板规范
- `01-原始素材区/`:从旧目录复制而来的原始内容副本,只做来源保留,不在这里直接做结构化沉淀
- `02-内容单元库/`:结构化结果,按内容单元类型管理
- `03-处理状态/`:处理进度、样本计划、冲突记录、抽取日志
- `04-模板/`:内容单元模板
- `05-主题地图/`:主题层组织文件
- `06-选题装配/`:面向选题输出的重组稿
- `07-脚本与工具/`:索引、校验、补链、草稿生成脚本
## 基本对象
本工程的基本对象统一称为「内容单元」。
第一期只保留 5 类:
- 问题单元
- 概念单元
- 观点单元
- 案例单元
- 方案单元
证据不作为独立类型,先挂在观点单元或案例单元内部。
## 存储规则
- 每个内容单元必须是一个独立的 Markdown 文件
- 每个文件开头必须使用 YAML frontmatter
- 文件名统一为 `ID_标题.md`
- 当前文件始终代表当前有效版本,历史变化交给 Git
- `frontmatter` 内的 `id`、`relationships.target` 一律保留结构化 ID,不写 `Obsidian` 链接
- 正文里凡是引用其他内容单元、主题地图、选题装配,统一写为 `[[文件名]]`,不要只写文件编号
- 批量新增或迁移正文引用后,运行 `node 07-脚本与工具/补全Obsidian链接.js`
- `内容单元` 的正文 `## 关联单元` 必须覆盖 `frontmatter.relationships.target` 对应的文件链接;允许额外补充手工关联,但不能漏掉已声明关系
## 处理流程
处理新文稿时,按以下顺序执行:
1. 判断素材是否进入本系统边界
2. 为原始素材建立来源位置与来源 ID
3. 判断应抽取哪些内容单元
4. 判断是否与现有内容单元重复
5. 需要合并时指定主单元
6. 不合并时建立必要关系
7. 更新处理状态与抽取日志
完整操作说明见 `00-规则与索引/新增文稿进入系统流程.md`。
## 关系规则
第一期只允许 4 种关系:
- `回应`
- `解释`
- `证明`
- `冲突`
关系明细见 `00-规则与索引/内容单元关系规则.md`。
## 去重与版本规则
- 重复只分为:完全重复、同义重复、近似重复、重复讲述
- 只有完全重复与同义重复默认合并
- 合并后必须指定主单元
- 只有语义或适用边界发生变化,才提升 `version`
规则明细见 `00-规则与索引/内容单元去重与版本规则.md`。
## Source of Truth
当需要定位原始素材、判断版本、确认处理状态或解决冲突时,先读 `SOURCE_OF_TRUTH.md`。
进入正式处理链的原始素材,必须先登记 `03-处理状态/来源注册表.csv`,再引用到内容单元的 `source_documents` 字段。
## 禁止事项
- 不得直接修改 `01-原始素材区/` 中的原始副本内容
- 不得把长篇整稿直接塞进 `02-内容单元库/`
- 不得只建立 `related_units` 这类泛关联而不写明确关系类型
- 不得在未判断重复前批量新建同类内容单元
scaffold/root/CLAUDE.md›
# CLAUDE.md
This file provides guidance to Claude Code when working with this repository.
## Project Overview
This repository is a structured content system for local content assets.
The project stores raw content copies and converts them into reusable content units that can later be recombined into new topics, scripts, and articles.
## Read First
Before locating files, resolving conflicts, or deciding whether a source has already been processed, read `SOURCE_OF_TRUTH.md`.
## Core Rules
- Treat `01-原始素材区/` as immutable source copies
- Create structured outputs only under `02-内容单元库/`
- Update process tracking files under `03-处理状态/` when new sources are processed
- Follow the rules in `00-规则与索引/`
## Content Unit Types
Phase 1 supports only:
- 问题单元
- 概念单元
- 观点单元
- 案例单元
- 方案单元
Evidence is embedded inside viewpoint units or case units rather than stored as an independent type.
## Naming Rules
- Content unit file name: `ID_标题.md`
- Source IDs use the `SRC-*` pattern
- Content unit IDs use type prefixes such as `QST`、`CON`、`OPI`、`CAS`、`SOL`
scaffold/root/README.md›
# 内容结构化系统
这是一个把原始内容资产加工为「内容单元」的新工程。
## 先看哪里
1. `SOURCE_OF_TRUTH.md`
2. `AGENTS.md`
3. `03-处理状态/处理状态总览.md`
## 当前默认目标
- 新工程目录已建立
- 规则文件、模板文件、状态文件已建立
- 原始素材副本已复制到 `01-原始素材区/完整副本/`
- 已建立来源注册、原始索引、待处理清单
- 已完成首批样本文稿的内容单元抽取
- 已建立关系索引与关系总览
- 已建立去重候选索引与去重总览
- 已建立主题地图和选题装配层
- 已建立系统总览脚本
## 当前真实基线
- 内容单元:以 `node 07-脚本与工具/summarize-system.js` 输出为准
- 主题地图:以 `node 07-脚本与工具/summarize-system.js` 输出为准
- 装配稿:以 `node 07-脚本与工具/summarize-system.js` 输出为准
- 关系总数:以 `03-处理状态/关系总览.md` 为准
- 去重候选:以 `03-处理状态/去重与冲突总览.md` 为准
说明:静态数字容易过期,不把固定统计写死在本文件里。
## 当前核心目录
- `00-规则与索引/`
- `01-原始素材区/`
- `02-内容单元库/`
- `03-处理状态/`
- `04-模板/`
- `05-主题地图/`
- `06-选题装配/`
- `07-脚本与工具/`
## 处理入口
- 批量生成来源候选:`node 07-脚本与工具/generate-source-registry.js`
- 重建原始索引与待处理清单:`node 07-脚本与工具/rebuild-processing-ledger.js`
- 自动抽取首批样本:`node 07-脚本与工具/extract-sample-units.js --help`
- 基于真实单元重组新选题:`node 07-脚本与工具/assemble-topic-from-units.js --title '你的选题' ...`
- 按选题自动推荐第一版装配:`node 07-脚本与工具/assemble-topic-from-units.js --title '年轻人怎么赚钱' --auto --top 3`
- 生成关系索引:`node 07-脚本与工具/generate-link-map.js`
- 生成去重候选:`node 07-脚本与工具/generate-duplicate-candidates.js`
- 补全 `Obsidian` 链接:`node 07-脚本与工具/fill-obsidian-links.js`
- 输出系统总览:`node 07-脚本与工具/summarize-system.js`
## Obsidian 链接约定
- `frontmatter` 中继续保留单元 `id`,供脚本、索引和关系字段使用
- 正文里引用其他内容单元、主题地图或装配稿时,统一使用 `Obsidian` `[[文件名]]` 直链
- 如果新增或修改了大量正文引用,可运行:`node 07-脚本与工具/fill-obsidian-links.js`
## 当前可验证能力
- 可以按语义块抽出多个真实单元,而不是每篇固定生成占位文件
- 可以生成可点击的主题地图与装配稿,并直接在 `Obsidian` 中跳转
- 可以基于现有内容单元,跨主题重组出新的选题装配稿
- 自动推荐不再只会回到原主题内部,已经可以给宽题拉出跨主题主轴
scaffold/root/SOURCE_OF_TRUTH.md›
# SOURCE_OF_TRUTH.md
> 本文件是 `内容结构化系统` 的数据权威索引。
> 当需要定位来源、判断处理状态、确认主单元、解决重复冲突时,先读本文件。
---
## 一、系统快速查找
| 要找什么 | 权威位置 | 备注 |
| --- | --- | --- |
| 系统规则总入口 | `AGENTS.md` | 处理流程、目录职责、禁止事项 |
| Claude 侧说明 | `CLAUDE.md` | 给 Claude Code 的仓库说明 |
| 处理流程总入口 | `00-规则与索引/处理流程.md` | 系统级处理步骤与入口说明 |
| 内容单元关系规则 | `00-规则与索引/内容单元关系规则.md` | 第一期开启的关系类型与方向 |
| 内容单元去重与版本规则 | `00-规则与索引/内容单元去重与版本规则.md` | 合并与版本升级规则 |
| 内容单元字段规范 | `00-规则与索引/内容单元字段规范.md` | 公共字段与类型字段 |
| 来源命名规范 | `00-规则与索引/来源命名规范.md` | `SRC-*` 的生成与使用规则 |
| 新增文稿处理流程 | `00-规则与索引/新增文稿进入系统流程.md` | 单篇新素材进入系统的标准顺序 |
| 内容单元模板 | `04-模板/` | 5 类内容单元模板 |
| 主题地图主库 | `05-主题地图/` | 主题层重组入口与跨单元组织层 |
| 选题装配主库 | `06-选题装配/` | 文章 / 视频 / 选题的重组骨架 |
| `Obsidian` 链接补全脚本 | `07-脚本与工具/fill-obsidian-links.js` | 把正文中的文件编号补成 `[[文件名]]` |
| 关系索引脚本 | `07-脚本与工具/generate-link-map.js` | 重建关系明细与关系总览 |
| 去重候选索引脚本 | `07-脚本与工具/generate-duplicate-candidates.js` | 重建去重候选、审计与总览 |
| 系统总览脚本 | `07-脚本与工具/summarize-system.js` | 输出当前内容单元、主题地图、装配稿数量 |
| 样本抽取脚本 | `07-脚本与工具/extract-sample-units.js` | 从样本文稿抽取第一批内容单元草稿 |
| 原始素材副本 | `01-原始素材区/` | 从旧目录复制而来,只做来源保留 |
| 内容单元主库 | `02-内容单元库/` | 结构化后的有效单元 |
| 处理状态 | `03-处理状态/处理状态总览.md` | 当前范围、已完成事项、下一步 |
| 来源注册表 | `03-处理状态/来源注册表.csv` | 已分配来源 ID 的权威表 |
| 批量来源注册候选表 | `03-处理状态/来源注册表_批量生成候选.csv` | 脚本生成的待人工确认来源表 |
| 原始素材索引重建脚本 | `07-脚本与工具/rebuild-processing-ledger.js` | 重新生成 `原始素材索引.csv` 与 `待处理清单.csv` |
| 已处理清单 | `03-处理状态/已处理清单.csv` | 已进入结构化处理链的素材 |
| 抽取日志 | `03-处理状态/抽取日志.md` | 每次抽取的操作记录 |
| 样本计划 | `03-处理状态/第一批样本计划.md` | 样本范围与当前状态 |
| 关系明细索引 | `03-处理状态/关系索引.csv` | 内容单元之间的显式关系明细 |
| 关系总览 | `03-处理状态/关系总览.md` | 当前关系数量、类型分布与校验结果 |
| 去重候选索引 | `03-处理状态/去重候选索引.csv` | 同类型内容单元的近似重复候选清单 |
| 去重与冲突审计 | `03-处理状态/去重与冲突审计.csv` | 冲突回写与分层关系校验明细 |
| 去重与冲突总览 | `03-处理状态/去重与冲突总览.md` | 当前去重候选数量与冲突处理说明 |
---
## 二、目录职责
| 目录 | 放什么 | 不放什么 |
| --- | --- | --- |
| `00-规则与索引/` | 系统规则、字段、关系、去重、模板规范 | 原始文稿、结构化结果 |
| `01-原始素材区/` | 原始文稿副本、原始索引、原始参考材料 | 加工后的内容单元 |
| `02-内容单元库/` | 可复用、可追溯的内容单元 | 未筛选的整篇文稿 |
| `03-处理状态/` | 处理进度、样本计划、冲突与日志 | 内容正文资产 |
| `04-模板/` | 内容单元模板 | 具体业务内容 |
| `05-主题地图/` | 主题层组织文件、跨单元重组入口 | 单个内容单元正文、原始文稿 |
| `06-选题装配/` | 面向发布的装配稿、表达骨架、重组路径 | 原始素材副本、规则文档 |
| `07-脚本与工具/` | 索引重建、补链、去重、总览等辅助脚本 | 业务内容正文主库 |
---
## 三、原始素材权威规则
1. `01-原始素材区/` 中的内容都是从旧目录复制而来的副本
2. 原始副本不得直接改写;如需清洗、提炼、重写,必须在 `02-内容单元库/` 完成
3. 同一原始素材如在多个来源出现,以信息更完整、时间更明确、上下文更完整的版本为准
4. 已正式进入处理链的原始素材,必须先登记到 `03-处理状态/来源注册表.csv`
---
## 四、内容单元权威规则
1. `02-内容单元库/` 中 `canonical: true` 的文件视为当前主单元
2. 同一语义内容如出现多个近似版本,以主单元为当前有效引用对象
3. `source_documents` 是内容单元的来源追溯权威字段
4. `relationships` 是内容单元之间关系的权威字段
5. `version` 表示当前有效版本,历史变更交给 Git
---
## 五、重复与冲突规则
1. 只有 `完全重复` 与 `同义重复` 默认合并
2. `近似重复` 不合并,保留独立单元
3. 判断相反或适用边界不同的内容单元不合并,用 `冲突` 关系处理
4. 同一案例多次讲述时,默认并入主案例,除非叙述角度已经变化成新案例
---
## 六、当前范围边界
当前系统优先处理以下内容:
- 用户本人已发布内容
- 用户本人未发布但结构成熟的稿件
- 外部研究素材
如果后续进入其他类型内容,先结合方法论和用户沟通结果再决定是否纳入。
scaffold/rules/内容单元关系规则.md›
# 内容单元关系规则
第一期只允许 4 类关系:
- `回应`
- `解释`
- `证明`
- `冲突`
## 使用说明
### 回应
用于某个内容单元直接回应另一个问题、判断或方案。
### 解释
用于概念单元解释问题单元、观点单元或方案单元。
### 证明
用于案例单元证明观点单元、方案单元或问题判断。
### 冲突
用于两个内容单元在判断方向、适用边界或结论上存在直接冲突。
## 方向纪律
- `解释` 优先由概念单元指向被解释对象
- `证明` 优先由案例单元指向被证明对象
- `回应` 由回应方指向被回应对象
- `冲突` 建议双向建立,并补充 `note`
scaffold/rules/内容单元去重与版本规则.md›
# 内容单元去重与版本规则
## 去重类型
第一期只允许 4 类:
- `完全重复`
- `同义重复`
- `近似重复`
- `重复讲述`
## 处理规则
- `完全重复`:默认合并
- `同义重复`:默认合并
- `近似重复`:不合并,保留独立单元
- `重复讲述`:视叙述角度决定是否并入主单元
## 主单元规则
需要合并时,必须指定 `canonical: true` 的主单元作为当前有效对象。
## 版本规则
只有语义边界、适用范围、核心结论或关键步骤发生变化,才提升 `version`。
scaffold/rules/内容单元字段规范.md›
# 内容单元字段规范
## 通用字段
每个内容单元必须包含以下字段:
- `id`
- `type`
- `title`
- `source_documents`
- `source_authors`
- `themes`
- `keywords`
- `status`
- `canonical`
- `version`
- `created_at`
- `updated_at`
- `relationships`
## 类型字段
### 问题单元
- `question_text`
- `question_type`
- `user_stage`
- `applicable_topics`
### 概念单元
- `concept_definition`
- `concept_function`
### 观点单元
- `core_claim`
- `claim_scope`
- `why_it_matters`
### 案例单元
- `case_subject`
- `case_summary`
- `case_process`
- `case_result`
### 方案单元
- `target_problem`
- `solution_summary`
- `action_steps`
- `expected_result`
## relationships 写法
空关系统一写:
```yaml
relationships: []
```
存在关系时统一写:
```yaml
relationships:
- type: 解释
target: CON-20260602-001
note: 用于定义判断边界
```
scaffold/rules/处理流程.md›
# 处理流程
## 标准顺序
1. 锁定纳入边界与排除边界
2. 初始化工程骨架
3. 复制原始素材到 `01-原始素材区/完整副本/`
4. 生成来源注册候选与原始索引
5. 先按来源类型做分类:成稿、短稿、推文合集、转写稿、说明文件、中间产物
6. 对可处理来源执行归一化:切块、去噪、提轻量上下文,或直接进入成稿抽取
7. 运行抽取脚本,生成第一批内容单元草稿
8. 人工复核与补充关系
9. 生成关系索引、去重候选与系统总览
10. 建立主题地图与选题装配稿
## 当前默认目标
第一次交付以「系统能用了」为准,不以全量处理完成为准。
scaffold/rules/新增文稿进入系统流程.md›
# 新增文稿进入系统流程
## 单篇新文稿标准动作
1. 将原文副本放入 `01-原始素材区/完整副本/`
2. 在 `03-处理状态/来源注册表.csv` 中登记来源 ID
3. 运行 `node 07-脚本与工具/rebuild-processing-ledger.js`
4. 先判断该文稿属于哪种来源类型:成稿、短稿、推文合集、转写稿、说明文件、中间产物
5. 对可处理来源执行对应归一化动作:切块、去噪、提取轻量上下文,或保留原样进入结构化抽取
6. 只对适合抽取的文本生成或更新内容单元;不适合抽取的文件写入「已跳过」或「待人工复核」
7. 判断与已有单元的重复关系
8. 补写 `relationships`
9. 运行:
- `node 07-脚本与工具/generate-link-map.js`
- `node 07-脚本与工具/generate-duplicate-candidates.js`
- `node 07-脚本与工具/fill-obsidian-links.js`
10. 将该原文写入 `03-处理状态/已处理清单.csv`,并标明是「已抽取样本」「已跳过」还是「待人工复核」
11. 在 `03-处理状态/抽取日志.md` 记录本次处理
scaffold/rules/来源命名规范.md›
# 来源命名规范
## 来源 ID 结构
统一使用:
`SRC-类别代码-序号`
例如:
- `SRC-WX-001`
- `SRC-VIDEO-011`
- `SRC-BK-002`
## 类别代码建议
- `WX`:公众号文章
- `VIDEO`:短视频文稿
- `BK`:爆款文稿
- `CON`:观点与概念素材
- `EXT`:外部研究素材
- `USER`:用户本人其他素材
## 规则
- 同一路径一旦分配来源 ID,不重复分配
- 旧 ID 已经被内容单元使用时,不随意改号
- 批量脚本生成的是候选表,正式以 `来源注册表.csv` 为准
SKILL.md›
---
name: dbs-content-system
description: 把本地文稿、推文、选题、案例和课程稿整理成可持续生长、可重组复用的内容工程。用户要求结构化内容资产、整理旧素材或建立主题地图时使用。
---
# dbs-content-system:内容结构化系统
你是 dontbesilent 的内容结构化系统搭建 AI。你的任务不是整理几篇文案,也不是给用户提几条内容建议。你的任务是:当用户本地已经有足够多的内容资产时,把这些素材搭成一个可持续生长的本地内容工程。
**你交付的不是一份总结,而是一套能继续运转的系统。**
**本 skill 必须自包含。不要假设用户安装后还能读取仓库里的知识包、参考文档或额外支持文件。只要拿到这一个 `SKILL.md`,也必须能完整执行。**
**本 skill 不是轻量 prompt,而是单目录重型 skill。`SKILL.md`、脚手架、模板、脚本、文档都固定留在 `skills/dbs-content-system/` 目录内部,不依赖共享目录。**
---
## 一句话定义
`dbs-content-system` 解决的是:
**如何把本地大量内容资产,从“堆在很多文件夹里的库存”,变成“可复用、可追溯、可重组、可继续生长的内容结构化工程”。**
它处理的是:
- 大量文稿
- 推文与帖子
- 公众号文章
- 选题草稿
- 案例素材
- 课程稿
- 录音转写
- 历史爆款内容
它不处理的是:
- 单篇文案润色
- 标题优化
- 短视频开头优化
- 少量零散素材的轻量整理
- 没有内容积累时的空转搭系统
---
## 核心边界
### 原则 1:先审计,再建工程
不要一上来就新建目录、复制全部素材、开始抽取。
先判断两件事:
1. 用户本地内容量够不够
2. 用户要处理的内容边界清不清楚
如果内容量不够,或者边界没定清,直接指出,不进入重工程。
### 原则 2:默认目标不是“全量处理完”,而是“系统能用了”
大多数用户第一次做这种工程,不需要一口气把所有内容结构化完。
默认目标是把系统推进到可用态:
- 工程骨架完整
- 规则层完整
- 状态层完整
- 原始素材副本已建立
- 首批内容单元已抽取
- 主题地图和装配稿已出现
- 关系与去重索引已跑通
做到这里,系统就已经可以继续长。
### 原则 2.5:结构先于规模
内容结构化工程的第一任务,不是尽快把所有文稿都抽完,而是先验证结构。
如果内容单元边界、关系方向、去重规则、来源登记规则还没稳定,就直接全量推进,只会大规模制造后续返工。
所以这个 skill 必须按模式逐档升级,而不是假装自己一开始就适合全量跑库。
### 原则 3:原始素材不改写,只复制副本
原目录里的原文件不碰。
所有正式处理都在新工程里进行。原始素材统一复制到 `01-原始素材区/完整副本/`,只用于保留来源和回溯依据。
### 原则 4:对象不是文件,而是内容单元
你不是按文件夹整理内容。你要把内容拆成可复用的最小语义对象。
首期只保留 5 类内容单元:
- `QST`:问题单元
- `CON`:概念单元
- `OPI`:观点单元
- `CAS`:案例单元
- `SOL`:方案单元
---
## 什么时候用
当用户出现这些信号时,进入本 skill:
- 手里已经有很多内容,想系统整理
- 想把旧内容变成以后可以反复调用的资产
- 想做一个可以重组内容的本地工程
- 想在 `Obsidian` 里看到节点关系
- 想让 `Agent` 以后能围绕素材持续生成新内容
- 已经不缺灵感,缺的是旧内容调用效率
- 明确提到「内容结构化系统」「内容资产工程化」「内容单元」「主题地图」「选题装配」
如果用户只想改一篇内容,说明当前请求超出内容工程边界,保留用户已经提供的文稿和目标,再交回 `/dbs` 根据具体任务判断入口。不要由本 skill 直接选择下一个 Skill。
---
## 审计门槛
只有满足以下条件,才进入正式建工程。
### 数量门槛
满足以下任一条即可:
- 可处理文本文件不少于 `50` 个
- 或可提取正文总字数不少于 `80000` 字
### 来源维度门槛
至少命中以下 2 类:
- 本人内容
- 外部研究素材
- 多作者内容
- 多平台内容
### 边界门槛
用户必须至少说明:
- 哪些目录是这次要纳入的
- 哪些目录明确不纳入
- 当前优先处理什么类型内容
默认优先处理顺序:
1. 用户本人已发布内容
2. 用户本人未发布但较成熟的稿件
3. 外部研究素材
如果不满足门槛:
- 不创建完整工程
- 输出一份审计结论
- 说明为什么当前不适合做重工程
- 给出降级路径:轻量索引、先做小样本、或先收缩边界
---
## 默认输出位置
### 目录优先级
1. 用户明确指定新目录:用用户指定目录
2. 用户只给内容根目录、未给输出位置:在当前工作目录下新建
3. 当前目录明显不适合建工程:要求用户指定位置
### 工程命名
默认目录名:
`内容结构化系统`
如果用户明确给了项目名,沿用用户命名。
如果重名,追加日期后缀:
`内容结构化系统_YYYYMMDD`
---
## 标准工程结构
审计通过后,固定建立以下结构:
```text
{工程根}/
├── AGENTS.md
├── CLAUDE.md
├── SOURCE_OF_TRUTH.md
├── README.md
├── 00-规则与索引/
├── 01-原始素材区/
├── 02-内容单元库/
├── 03-处理状态/
├── 04-模板/
├── 05-主题地图/
├── 06-选题装配/
└── 07-脚本与工具/
```
根级固定文件职责:
- `AGENTS.md`:跨宿主规则、目录职责、处理纪律
- `CLAUDE.md`:Claude Code 侧说明
- `SOURCE_OF_TRUTH.md`:权威定位与冲突规则
- `README.md`:对外说明当前系统做到了什么
### 随 skill 一起交付的工具层
本 skill 自带以下可分发文件,安装后即应可用:
- `templates/`:7 份模板
- `scaffold/root/`:根级 `AGENTS.md`、`CLAUDE.md`、`README.md`、`SOURCE_OF_TRUTH.md`
- `scaffold/rules/`:6 份规则文件
- `docs/quickstart.md`:最短启动链路
- `docs/acceptance.md`:正式版验收标准
- `tools/init-content-system.js`:初始化工程骨架
- `tools/generate-source-registry.js`:批量生成来源注册候选
- `tools/rebuild-processing-ledger.js`:重建原始素材索引与待处理清单
- `tools/generate-unit-draft.js`:生成内容单元草稿
- `tools/extract-sample-units.js`:从样本文稿抽取第一批内容单元草稿
- `tools/generate-link-map.js`:生成关系索引与关系总览
- `tools/generate-duplicate-candidates.js`:生成去重候选、去重审计与冲突总览
- `tools/fill-obsidian-links.js`:把正文中的结构化 ID 补成 `[[文件名]]`
- `tools/summarize-system.js`:输出当前系统总览
如果用户安装后的 skill 包里没有这些文件,视为交付不完整。
---
## 内容单元标准
### 文件规则
- 每个内容单元必须是独立 Markdown 文件
- 文件名固定为 `ID_标题.md`
- 文件开头必须有 YAML frontmatter
- 当前文件代表当前有效版本,历史变化交给 Git
### 最小字段
每个内容单元至少包含:
- `id`
- `type`
- `title`
- `canonical`
- `version`
- `source_documents`
- `relationships`
### 关系类型
第一期只允许 4 类关系:
- `回应`
- `解释`
- `证明`
- `冲突`
### 去重类型
第一期只允许 4 类:
- `完全重复`
- `同义重复`
- `近似重复`
- `重复讲述`
只有 `完全重复` 与 `同义重复` 默认合并。
### 链接规则
- frontmatter 中的 `id`、`relationships.target` 保留结构化 ID
- 正文里引用其他内容单元、主题地图、装配稿时,统一写 `[[文件名]]`
---
## 工作流程
### 运行模式
本 skill 固定分为 4 个模式:
1. `审计模式`
2. `样本模式`
3. `批量模式`
4. `全量模式`
默认永远从 `审计模式` 进入。
只有前一档闸门全部通过,才允许进入下一档。少一条都不升档。
### Phase 1:审计输入目录
先做这些事:
1. 读取用户指定的内容目录
2. 统计可处理文件数
3. 估算文本规模
4. 识别主要内容类型
5. 判断哪些目录应纳入、哪些应排除
6. 判断是否满足数量门槛与边界门槛
审计输出必须明确:
- 当前素材规模
- 可纳入范围
- 明确排除项
- 是否达标
- 如果达标,建议输出目录
- 如果不达标,应该降级做什么
#### `审计模式 → 样本模式` 升档闸门
必须同时满足:
- 输入目录已经锁定:纳入哪些目录、排除哪些目录,必须写进状态文件
- 数量门槛达标:文本文件不少于 `50` 个,或正文不少于 `80000` 字
- 来源维度不少于 `2` 类:本人内容 / 多平台 / 多作者 / 外部研究素材
- 输出目录已确定:不直接在旧目录里动手
只要这 4 条有一条不成立,就停在审计模式,不进入样本处理。
### Phase 2:建立工程骨架
只有审计通过才执行:
1. 新建工程目录
2. 运行 `tools/init-content-system.js`
3. 写入 `AGENTS.md`
4. 写入 `CLAUDE.md`
5. 写入 `SOURCE_OF_TRUTH.md`
6. 写入 `README.md`
7. 建立 `00-07` 目录
8. 建立模板、规则、状态文件
### Phase 3:复制原始素材
把纳入范围的源目录复制到:
`01-原始素材区/完整副本/`
同时建立:
- 原始素材索引
- 待处理清单
- 来源注册表
原始副本不得改写。
复制完成后,立即运行:
`node 07-脚本与工具/generate-source-registry.js`
以及:
`node 07-脚本与工具/rebuild-processing-ledger.js`
### Phase 4:首批样本处理
默认先处理小样本,不一口气全量抽。
处理顺序:
1. 用户本人内容优先
2. 先挑高价值、代表性强的内容
3. 按文稿逐步抽取内容单元
4. 同步判断重复、关系与来源
#### 首批样本自动抽取协议
这里说的「自动抽取」,不是写一个虚假的全自动语义脚本批量乱拆,而是让 skill 直接按固定协议,从用户指定的 `3` 到 `5` 篇样本文稿里产出第一批内容单元。
必须按以下顺序执行:
1. 从已纳入目录中选 `3` 到 `5` 篇代表性样本文稿
2. 样本文稿优先顺序:
- 用户本人已发布内容
- 用户本人未发布但结构成熟的稿件
- 高密度方法论文稿
3. 对每篇样本文稿,强制抽取:
- `1` 个主问题单元 `QST`
- `1` 个主观点单元 `OPI`
- 如文中有稳定定义,再抽 `CON`
- 如文中有具体事件、数据或案例,再抽 `CAS`
- 如文中有明确动作路径,再抽 `SOL`
4. 每个新单元都必须补齐:
- `source_documents`
- `themes`
- `keywords`
- `relationships`
5. 抽完后立即做 3 件事:
- 判断是否与现有单元重复
- 判断是否需要建立 `回应 / 解释 / 证明 / 冲突`
- 更新来源注册表、已处理清单与处理状态总览
如果当前工程已有 `07-脚本与工具/generate-unit-draft.js`,优先用它落草稿文件,不要手工从零写空文件。
如果当前工程已有 `07-脚本与工具/extract-sample-units.js`,优先使用该脚本直接从样本文稿生成第一批单元草稿、主题地图和装配稿。
如果当前工程已有 `07-脚本与工具/assemble-topic-from-units.js`,需要验证「系统能不能真正重组内容」时,优先用它从现有真实单元生成新的选题装配稿,不要回退到直接重读原文再手写装配。
禁止做法:
- 不要假装可以一次把文稿里的所有语义对象抽全
- 不要不经判断就把每段话都拆成节点
- 不要在首批样本阶段为了追求数量制造大量低价值单元
首批样本抽取的目标不是覆盖全部语义,而是验证这套结构是否可维护。
#### `样本模式 → 批量模式` 升档闸门
必须同时满足:
- 样本覆盖至少 `3` 类来源
- 样本覆盖至少 `20` 篇原始文稿,或至少 `3` 个主题簇
- `QST / CON / OPI / CAS / SOL` 的判断口径已经稳定
- `回应 / 解释 / 证明 / 冲突` 的关系口径已经稳定
- `完全重复 / 同义重复 / 近似重复 / 重复讲述` 的去重口径已经稳定
- 关系校验通过:目标缺失数必须为 `0`
- 样本节点的来源追溯必须完整
- 至少已经跑出一轮主题地图和装配稿
- 状态层文件可重建:原始素材索引、待处理清单、已处理清单、来源注册表、关系索引、去重候选都能重新生成
只要这组闸门没全过,就继续留在样本模式,不进入批量推进。
默认可用态的最小目标:
- 至少产出 `15` 个内容单元
- 如不足,则继续到最多 `20` 篇样本
### Phase 5:建立主题地图与装配稿
在首批内容单元出来后:
1. 建立至少 `3` 张主题地图
2. 建立至少 `2` 份选题装配稿
主题地图的职责是聚合同主题节点。
选题装配稿的职责是把节点进一步变成可发布的表达骨架。
### Phase 6:关系、去重、总览校验
必须生成:
- 关系索引
- 关系总览
- 去重候选索引
- 去重与冲突总览
- 处理状态总览
如果这些索引没有跑通,不算交付完成。
其中至少要能直接运行以下命令:
- `node 07-脚本与工具/generate-source-registry.js`
- `node 07-脚本与工具/rebuild-processing-ledger.js`
- `node 07-脚本与工具/extract-sample-units.js --help`
- `node 07-脚本与工具/assemble-topic-from-units.js --title '示例选题' --question ... --concept ... --opinion ... --case ... --solution ...`
- `node 07-脚本与工具/generate-link-map.js`
- `node 07-脚本与工具/generate-duplicate-candidates.js`
- `node 07-脚本与工具/fill-obsidian-links.js`
- `node 07-脚本与工具/summarize-system.js`
### Phase 7:批量推进与全量推进
只有样本模式闸门通过,才进入这里。
#### 批量模式
- 按批次推进,不是一口气吃完整库
- 每批处理固定数量素材
- 每批素材先过来源分类器,再决定是跳过、归一化还是进入抽取
- 每批结束后必须复盘:字段是否改动、关系是否改动、去重是否失控、返工量是否异常
#### `批量模式 → 全量模式` 升档闸门
必须同时满足:
- 连续 `2` 个批次处理后,没有改字段规范
- 连续 `2` 个批次处理后,没有改关系规则
- 连续 `2` 个批次处理后,没有改去重规则
- 连续 `2` 个批次处理后,没有出现大面积返工
- 每批处理结束后,都能直接续跑下一批,不需要重建工程
- 人工抽查 `30` 个内容单元,重大误判不超过 `3` 个
- 去重候选没有失控堆积
只有这些条件全部成立,才允许进入全量模式。
#### 全量模式
- 对剩余待处理库存持续推进
- 以既有规则滚动扩展覆盖率
- 全量推进也必须保留「分类 → 归一化 → 抽取」链路,不得把所有文件重新降级成统一抽取入口
- 不得在全量模式里重新发明字段、关系或去重类型
---
## 可用态判定
只有同时满足以下条件,才可以说「系统能用了」:
- 完整工程骨架已建立
- 规则文件已写入
- 原始素材副本已复制
- 来源注册表、原始素材索引、待处理清单已存在
- 已抽取首批内容单元
- 已出现主题地图
- 已出现选题装配稿
- 已生成关系与去重索引
- `03-处理状态/处理状态总览.md` 已明确当前范围、未处理量与下一步入口
默认交付到这里即可,不承诺首次全量结构化完成。
---
## 对话与执行要求
- 不要停留在建议层
- 不要只给目录结构草图
- 用户已授权执行时,直接动手
- 每做完一个阶段,都要告诉用户当前完成到了哪一层
- 发现素材规模不足,直接指出,不要假装可以靠方法论弥补素材量
- 发现输入边界混乱,先收缩边界,再继续
---
## 与其他 skill 的关系
### 适合转入本 skill
- `/dbs-good-question` 已把问题说明书写清楚,且适合自动化执行
- `/dbs-agent-migration` 已经把 Agent 工作台迁好,下一步要搭内容工程
- 用户明确需要本地内容资产长期工程化
---
完成当前任务后直接结束。只有用户明确询问下一步,且当前环境已经安装 `/dbs` 时,简短提示:「下一步不确定时,可以输入 `/dbs`。」
templates/主题地图模板.md›
# 主题地图:主题名
## 主题定义
## 核心问题单元
## 核心概念单元
## 核心观点单元
## 核心案例单元
## 核心方案单元
## 常见装配路径
1. 问题:
2. 概念:
3. 观点:
4. 案例:
5. 方案:
## 相关主题
templates/方案单元模板.md›
---
id: SOL-YYYYMMDD-001
type: 方案单元
title: 标题
source_documents:
- SRC-*
source_authors:
- 待补
themes:
- 主题
keywords:
- 关键词
status: 待核对
canonical: true
version: 1
created_at: YYYY-MM-DD
updated_at: YYYY-MM-DD
target_problem: 解决什么问题
solution_summary: 方案摘要
action_steps:
- 步骤 1
expected_result: 预期结果
relationships: []
---
## 核心内容
## 来源依据
## 使用场景
## 关联单元
## 备注
templates/案例单元模板.md›
---
id: CAS-YYYYMMDD-001
type: 案例单元
title: 标题
source_documents:
- SRC-*
source_authors:
- 待补
themes:
- 主题
keywords:
- 关键词
status: 待核对
canonical: true
version: 1
created_at: YYYY-MM-DD
updated_at: YYYY-MM-DD
case_subject: 案例主体
case_summary: 案例摘要
case_process: 关键过程
case_result: 结果
relationships: []
---
## 核心内容
## 来源依据
## 使用场景
## 关联单元
## 备注
templates/概念单元模板.md›
---
id: CON-YYYYMMDD-001
type: 概念单元
title: 标题
source_documents:
- SRC-*
source_authors:
- 待补
themes:
- 主题
keywords:
- 关键词
status: 待核对
canonical: true
version: 1
created_at: YYYY-MM-DD
updated_at: YYYY-MM-DD
concept_definition: 概念定义
concept_function: 解释什么
relationships: []
---
## 核心内容
## 来源依据
## 使用场景
## 关联单元
## 备注
templates/观点单元模板.md›
---
id: OPI-YYYYMMDD-001
type: 观点单元
title: 标题
source_documents:
- SRC-*
source_authors:
- 待补
themes:
- 主题
keywords:
- 关键词
status: 待核对
canonical: true
version: 1
created_at: YYYY-MM-DD
updated_at: YYYY-MM-DD
core_claim: 核心判断
claim_scope: 适用范围
why_it_matters: 为什么重要
relationships: []
---
## 核心内容
## 来源依据
## 使用场景
## 关联单元
## 备注
templates/选题装配模板.md›
# 选题装配:标题
## 目标受众
## 装配理由
## 核心调用单元
### 问题
### 概念
### 观点
### 案例
### 方案
## 可追加调用单元
- 补充问题:
- 补充概念:
- 补充观点:
- 补充案例:
- 补充方案:
## 建议结构
1. 痛点:
2. 冲突:
3. 展开:
4. 案例:
5. 方法:
6. 收束:
## 表达骨架
### 开头
### 中段 1
### 中段 2
### 中段 3
### 结尾
templates/问题单元模板.md›
---
id: QST-YYYYMMDD-001
type: 问题单元
title: 标题
source_documents:
- SRC-*
source_authors:
- 待补
themes:
- 主题
keywords:
- 关键词
status: 待核对
canonical: true
version: 1
created_at: YYYY-MM-DD
updated_at: YYYY-MM-DD
question_text: 问题原句
question_type: 认知问题
user_stage: 起步期
applicable_topics:
- 适用选题
relationships: []
---
## 核心内容
## 来源依据
## 使用场景
## 关联单元
## 备注
tools/assemble-topic-from-units.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const unitRoot = path.join(root, "02-内容单元库");
const assemblyRoot = path.join(root, "06-选题装配");
const typeDirs = {
QST: "问题单元",
CON: "概念单元",
OPI: "观点单元",
CAS: "案例单元",
SOL: "方案单元",
};
function fail(message) {
console.error(message);
process.exit(1);
}
function parseArgs(argv) {
const result = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) result[key] = true;
else {
result[key] = next;
i += 1;
}
}
return result;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function slugFromTitle(title) {
return title.replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim() || "未命名选题";
}
function splitList(value) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function readFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
return match ? match[1] : "";
}
function readBody(content) {
const match = content.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : content;
}
function getField(frontmatter, field) {
const match = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
return match ? match[1].trim() : "";
}
function getListField(frontmatter, field) {
const match = frontmatter.match(new RegExp(`^${field}:\\n((?:\\s+-\\s+.+\\n?)*)`, "m"));
if (!match) return [];
return match[1]
.split("\n")
.map((line) => line.trim().replace(/^- /, "").trim())
.filter(Boolean);
}
function getRelationshipTargets(frontmatter) {
return [...frontmatter.matchAll(/^\s*target:\s*(.+)$/gm)]
.map((match) => match[1].trim())
.filter(Boolean);
}
function getSection(body, heading) {
const match = body.match(new RegExp(`## ${heading}\\n\\n([\\s\\S]*?)(?=\\n## |$)`));
return match ? match[1].trim() : "";
}
function summarizeUnit(unit) {
if (unit.prefix === "QST") return unit.fieldValue("question_text") || unit.section("核心内容");
if (unit.prefix === "CON") return unit.fieldValue("concept_definition") || unit.section("核心内容");
if (unit.prefix === "OPI") return unit.fieldValue("core_claim") || unit.section("核心内容");
if (unit.prefix === "CAS") return unit.fieldValue("case_summary") || unit.section("核心内容");
if (unit.prefix === "SOL") return unit.fieldValue("solution_summary") || unit.section("核心内容");
return unit.section("核心内容");
}
function oneLine(text) {
return String(text || "").replace(/\s+/g, " ").trim();
}
function tokenize(text) {
const normalized = String(text || "").toLowerCase().trim();
const parts = normalized
.replace(/[^\p{Script=Han}a-z0-9]+/gu, " ")
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
const tokens = new Set();
for (const part of parts) {
if (part.length >= 2) tokens.add(part);
if (/^\p{Script=Han}+$/u.test(part)) {
for (let size = 2; size <= 3; size += 1) {
if (part.length < size) continue;
for (let i = 0; i <= part.length - size; i += 1) {
tokens.add(part.slice(i, i + size));
}
}
}
}
return [...tokens];
}
function normalizeAssemblyTitle(title) {
return String(title || "")
.replace(/([^)]*)/g, " ")
.replace(/\([^)]*\)/g, " ")
.replace(/自动推荐版|结构化重组版|调试/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function expandIntentTokens(title, query) {
const source = `${normalizeAssemblyTitle(title)} ${query || ""}`;
const extras = [];
if (/年轻人/.test(source) && /赚钱|变现|收入/.test(source)) {
extras.push("兴趣", "变现", "需求", "生意", "具体业务", "老板思维", "生产型兴趣");
}
if (/赚钱|变现|收入/.test(source) && !/被动收入|系统赚钱/.test(source)) {
extras.push("需求", "变现", "生意");
}
if (/兴趣/.test(source) && /赚钱|变现/.test(source)) {
extras.push("生产型兴趣", "具体业务", "能力");
}
if (/创业|生意/.test(source)) {
extras.push("需求", "具体生意", "从右到左");
}
return tokenize(extras.join(" "));
}
function findUnitFile(ref) {
const normalized = ref.replace(/^\[\[|\]\]$/g, "").replace(/\.md$/i, "");
for (const dir of Object.values(typeDirs)) {
const direct = path.join(unitRoot, dir, `${normalized}.md`);
if (fs.existsSync(direct)) return direct;
}
if (!normalized.includes("_")) {
for (const dir of Object.values(typeDirs)) {
const fullDir = path.join(unitRoot, dir);
if (!fs.existsSync(fullDir)) continue;
for (const entry of fs.readdirSync(fullDir)) {
if (!entry.endsWith(".md")) continue;
if (!entry.startsWith(`${normalized}_`)) continue;
return path.join(fullDir, entry);
}
}
}
fail(`找不到内容单元:${ref}`);
}
function loadUnit(ref) {
const filePath = findUnitFile(ref);
const content = fs.readFileSync(filePath, "utf8");
const frontmatter = readFrontmatter(content);
const body = readBody(content);
const id = getField(frontmatter, "id");
const title = getField(frontmatter, "title");
const prefix = id.split("-")[0];
const basename = path.basename(filePath, ".md");
return {
id,
prefix,
title,
basename,
filePath,
fieldValue(field) {
return getField(frontmatter, field);
},
listField(field) {
return getListField(frontmatter, field);
},
relationshipTargets() {
return getRelationshipTargets(frontmatter);
},
section(heading) {
return getSection(body, heading);
},
summary: summarizeUnit({
prefix,
fieldValue: (field) => getField(frontmatter, field),
section: (heading) => getSection(body, heading),
}),
};
}
function loadAllUnits() {
const units = [];
for (const dir of Object.values(typeDirs)) {
const fullDir = path.join(unitRoot, dir);
if (!fs.existsSync(fullDir)) continue;
for (const entry of fs.readdirSync(fullDir)) {
if (!entry.endsWith(".md")) continue;
units.push(loadUnit(path.basename(entry, ".md")));
}
}
return units;
}
function buildIntentProfile(title, query) {
const source = `${normalizeAssemblyTitle(title)} ${query || ""}`;
return {
money: /赚钱|变现|收入|生意/.test(source),
young: /年轻人/.test(source),
interest: /兴趣|爱好/.test(source) || (/年轻人/.test(source) && /赚钱|变现|收入/.test(source)),
demand: /需求|老板思维|创业|生意|业务/.test(source) || (/赚钱|变现|收入/.test(source) && /年轻人|创业|生意/.test(source)),
business: /生意|业务|创业/.test(source) || /赚钱|变现|收入/.test(source),
passive: /被动收入|系统赚钱/.test(source),
stability: /稳定|反脆弱/.test(source),
};
}
function unitText(unit) {
return [
unit.title,
unit.summary,
unit.fieldValue("question_text"),
unit.fieldValue("core_claim"),
unit.fieldValue("case_summary"),
unit.fieldValue("case_result"),
unit.fieldValue("solution_summary"),
unit.listField("themes").join(" "),
unit.listField("keywords").join(" "),
unit.listField("applicable_topics").join(" "),
]
.filter(Boolean)
.join(" ");
}
function hasThemeSignal(unit, pattern) {
return pattern.test(unitText(unit));
}
function getThemes(unit) {
return unit.listField("themes").map((item) => item.trim()).filter(Boolean);
}
function sharesTheme(a, b) {
const aThemes = new Set(getThemes(a));
const bThemes = getThemes(b);
return bThemes.some((theme) => aThemes.has(theme));
}
function scoreIntentAlignment(unit, intentProfile) {
const text = unitText(unit);
let score = 0;
if (intentProfile.money && /赚钱|变现|收入|生意|需求|业务/.test(text)) score += 4;
if (intentProfile.interest) {
if (/兴趣|生产型兴趣|消费型兴趣/.test(text)) score += 6;
if (unit.prefix === "QST" && /什么样的兴趣|兴趣.*变现|变现.*兴趣/.test(text)) score += 10;
if (unit.prefix === "CON" && /生产型兴趣|消费型兴趣/.test(text)) score += 12;
if (unit.prefix === "SOL" && /兴趣变现|生产性|具体业务/.test(text)) score += 8;
}
if (intentProfile.demand) {
if (/需求|老板思维|从需求倒推|从右到左|具体业务/.test(text)) score += 8;
if (unit.prefix === "OPI" && /先看需求|老板思维|思维顺序错了|从需求出发|倒推产品/.test(text)) score += 20;
if (unit.prefix === "QST" && /能力.*变现|赚不到钱|需求倒推/.test(text)) score += 6;
if (unit.prefix === "SOL" && /从需求出发|对标|倒推/.test(text)) score += 7;
if (unit.prefix === "CAS" && /柠檬水|需求先于原料/.test(text)) score += 5;
}
if (intentProfile.young && intentProfile.money) {
if (unit.prefix === "QST" && /什么样的兴趣能真正变现/.test(text)) score += 8;
if (unit.prefix === "OPI" && /先看需求|能力不够|老板/.test(text)) score += 12;
}
if (intentProfile.passive) {
if (/被动收入|系统赚钱/.test(text)) score += 10;
}
if (intentProfile.stability) {
if (/稳定|反脆弱|保险/.test(text)) score += 8;
}
if (unit.prefix === "OPI" && /为什么要找对标/.test(unit.title)) score -= 8;
if (unit.prefix === "OPI" && /问题:|答案:|逻辑:/.test(text)) score -= 5;
return score;
}
function scoreUnit(unit, primaryTokens, queryTokens, intentProfile) {
const titleTokens = tokenize(unit.title);
const keywordTokens = tokenize(unit.listField("keywords").join(" "));
const themeTokens = tokenize(unit.listField("themes").join(" "));
const summaryTokens = tokenize(unit.summary);
const applicableTokens = tokenize(unit.listField("applicable_topics").join(" "));
const pool = [
...titleTokens,
...keywordTokens,
...themeTokens,
...summaryTokens,
...applicableTokens,
];
const poolSet = new Set(pool);
let score = 0;
for (const token of primaryTokens) {
if (titleTokens.includes(token)) score += 14;
if (keywordTokens.includes(token)) score += 9;
if (themeTokens.includes(token)) score += 7;
if (applicableTokens.includes(token)) score += 5;
if (summaryTokens.includes(token)) score += 4;
if (poolSet.has(token)) score += 2;
}
for (const token of queryTokens) {
if (titleTokens.includes(token)) score += 8;
if (keywordTokens.includes(token)) score += 5;
if (themeTokens.includes(token)) score += 4;
if (applicableTokens.includes(token)) score += 3;
if (summaryTokens.includes(token)) score += 2;
if (poolSet.has(token)) score += 1;
}
if (unit.prefix === "QST" && /怎么|如何|为什么|能不能|赚钱|变现|收入|生意/.test(unit.title + unit.summary)) score += 2;
if (unit.prefix === "SOL" && /路径|方案|步骤|检查法|方法/.test(unit.title + unit.summary)) score += 2;
if (unit.prefix === "CAS" && /案例|证明|过程|结果|数据/.test(unit.summary)) score += 1;
if (unit.prefix === "OPI" && /本质|核心|判断|不要|先/.test(unit.summary)) score += 1;
if (/概念图谱|用途:|底层概念坐标系|关键概念$|核心问题$|核心观点$|关键案例$|可执行方案$/.test(unit.title + " " + unit.summary)) {
score -= 10;
}
if (unit.prefix === "QST" && !/[??]|为什么|如何|怎么|能不能|怎样/.test(unit.fieldValue("question_text") || unit.summary)) {
score -= 6;
}
if (unit.prefix === "OPI" && !/本质|核心|不要|先|应该|不是|而是|判断|根本/.test(unit.fieldValue("core_claim") || unit.summary)) {
score -= 4;
}
if (unit.prefix === "CAS" && !/案例|过程|结果|证明|数据|月均|粉丝|播放|增长|收益/.test(unit.fieldValue("case_summary") + " " + unit.fieldValue("case_result") + " " + unit.summary)) {
score -= 4;
}
if (unit.prefix === "SOL" && !/步骤|方案|路径|检查法|先|再|最后/.test(unit.fieldValue("solution_summary") + " " + unit.summary)) {
score -= 4;
}
score += scoreIntentAlignment(unit, intentProfile);
return score;
}
function scoreMainSelection(unit, prefix, intentProfile, context) {
const text = unitText(unit);
const targets = unit.relationshipTargets();
let score = 0;
const isHybridMoneyTopic =
intentProfile.young && intentProfile.money && intentProfile.interest && intentProfile.demand;
if (context.mainQuestion) {
if (targets.includes(context.mainQuestion.id)) score += isHybridMoneyTopic && prefix === "OPI" ? 6 : 18;
if (sharesTheme(unit, context.mainQuestion)) score += 6;
}
if (context.mainConcept) {
if (targets.includes(context.mainConcept.id)) score += 10;
if (sharesTheme(unit, context.mainConcept)) score += 5;
}
if (context.mainOpinion) {
if (targets.includes(context.mainOpinion.id)) score += 20;
if (sharesTheme(unit, context.mainOpinion)) score += 6;
}
if (prefix === "QST") {
if (intentProfile.money && intentProfile.interest && /什么样的兴趣能真正变现/.test(text)) score += 18;
if (intentProfile.demand && !intentProfile.interest && /为什么能力强的人离开公司后仍然赚不到钱/.test(text)) score += 18;
if (intentProfile.demand && /能力怎么变现|老板思维|需求倒推/.test(text)) score += 8;
}
if (prefix === "CON") {
if (intentProfile.interest && /生产型兴趣|消费型兴趣/.test(text)) score += 18;
if (intentProfile.demand && !intentProfile.interest && /员工思维|老板思维/.test(text)) score += 18;
if (intentProfile.money && intentProfile.young && /生产型兴趣|消费型兴趣/.test(text)) score += 10;
}
if (prefix === "OPI") {
if (intentProfile.money && intentProfile.demand && /先看需求|老板思维|从需求出发|倒推产品/.test(text)) score += 24;
if (intentProfile.interest && !intentProfile.demand && /兴趣能不能变现|生产型兴趣/.test(text)) score += 16;
if (isHybridMoneyTopic) {
if (/先看需求|老板思维|从需求出发|倒推产品/.test(text)) score += 48;
if (/兴趣能不能变现,不取决于兴趣这个标签本身/.test(text)) score -= 20;
}
if (/为什么要找对标/.test(unit.title)) score -= 10;
if (/为什么别人做不了答疑群/.test(unit.title) && intentProfile.demand) score -= 8;
}
if (prefix === "CAS") {
if (intentProfile.money && intentProfile.demand && /需求先于原料|先写课程大纲|需求先于产品/.test(text)) score += 14;
if (intentProfile.interest && /公开输出|数据验证/.test(text)) score += 10;
if (intentProfile.young && intentProfile.money && intentProfile.demand && /需求先于原料|需求先于产品/.test(text)) score += 8;
}
if (prefix === "SOL") {
if (intentProfile.interest && /兴趣变现三要素|生产性|具体业务/.test(text)) score += 14;
if (intentProfile.demand && /从需求出发|对标|倒推/.test(text)) score += 14;
if (intentProfile.young && intentProfile.money && intentProfile.demand && /从需求出发|倒推/.test(text)) score += 10;
if (context.mainOpinion && targets.includes(context.mainOpinion.id)) score += 12;
}
return score;
}
function isHybridMoneyTopic(intentProfile) {
return intentProfile.young && intentProfile.money && intentProfile.interest && intentProfile.demand;
}
function isDemandDrivenOpinion(unit) {
return /先看需求|老板思维|从需求出发|倒推产品/.test(unitText(unit));
}
function isDemandDrivenSolution(unit) {
return /从需求出发|先看需求|从右到左|对标|倒推/.test(unitText(unit));
}
function rankUnitsForType(candidates, prefix, topPerType, intentProfile, context = {}) {
if (candidates.length === 0) return [];
const sorted = [...candidates].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.unit.title.localeCompare(b.unit.title, "zh-Hans-CN");
});
const mainSorted = [...sorted].sort((a, b) => {
const aScore = a.score + scoreMainSelection(a.unit, prefix, intentProfile, context);
const bScore = b.score + scoreMainSelection(b.unit, prefix, intentProfile, context);
if (bScore !== aScore) return bScore - aScore;
return a.unit.title.localeCompare(b.unit.title, "zh-Hans-CN");
});
let primaryUnit = mainSorted[0].unit;
if (prefix === "OPI" && isHybridMoneyTopic(intentProfile)) {
const demandDriven = mainSorted.find((item) => isDemandDrivenOpinion(item.unit));
if (demandDriven) primaryUnit = demandDriven.unit;
}
if (prefix === "SOL" && isHybridMoneyTopic(intentProfile) && context.mainOpinion && isDemandDrivenOpinion(context.mainOpinion)) {
const demandDriven = mainSorted.find((item) => isDemandDrivenSolution(item.unit));
if (demandDriven) primaryUnit = demandDriven.unit;
}
const selected = [primaryUnit];
const remaining = sorted.slice(1).map((item) => {
let bonus = 0;
const primary = selected[0];
if (!sharesTheme(item.unit, primary)) bonus += 3;
if (intentProfile.money && intentProfile.demand) {
if (prefix === "OPI" && hasThemeSignal(primary, /兴趣|生产型兴趣/) && hasThemeSignal(item.unit, /需求|老板思维|从需求倒推/)) {
bonus += 8;
}
if (prefix === "SOL" && hasThemeSignal(item.unit, /从需求出发|对标|倒推/)) {
bonus += 4;
}
if (prefix === "CAS" && hasThemeSignal(item.unit, /需求先于原料|数据验证|公开输出/)) {
bonus += 3;
}
}
return { ...item, score: item.score + bonus };
});
remaining.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.unit.title.localeCompare(b.unit.title, "zh-Hans-CN");
});
for (const item of remaining) {
if (selected.length >= topPerType) break;
if (selected.some((unit) => unit.id === item.unit.id)) continue;
selected.push(item.unit);
}
return selected;
}
function recommendUnitsByQuery(title, query, topPerType = 3) {
const primaryTokens = tokenize(normalizeAssemblyTitle(title));
const queryTokens = tokenize(query);
const intentTokens = expandIntentTokens(title, query);
const intentProfile = buildIntentProfile(title, query);
if (queryTokens.length === 0 && primaryTokens.length === 0) fail("query 和 title 不能同时为空");
const allUnits = loadAllUnits();
const grouped = { QST: [], CON: [], OPI: [], CAS: [], SOL: [] };
for (const unit of allUnits) {
const summary = `${unit.summary} ${unit.fieldValue("question_text")} ${unit.fieldValue("core_claim")} ${unit.fieldValue("case_summary")} ${unit.fieldValue("solution_summary")}`;
if (/概念图谱/.test(unit.title) || /^> 用途:/.test(summary) || /底层概念坐标系/.test(summary)) continue;
if (/ 的关键概念$| 的核心问题$| 的核心观点$| 的关键案例$| 的可执行方案$/.test(unit.title)) continue;
const score = scoreUnit(unit, [...primaryTokens, ...intentTokens], queryTokens, intentProfile);
if (score <= 0) continue;
grouped[unit.prefix].push({ unit, score });
}
const context = {};
grouped.QST = rankUnitsForType(grouped.QST, "QST", topPerType, intentProfile, context);
context.mainQuestion = grouped.QST[0];
grouped.CON = rankUnitsForType(grouped.CON, "CON", topPerType, intentProfile, context);
context.mainConcept = grouped.CON[0];
grouped.OPI = rankUnitsForType(grouped.OPI, "OPI", topPerType, intentProfile, context);
context.mainOpinion = grouped.OPI[0];
grouped.CAS = rankUnitsForType(grouped.CAS, "CAS", topPerType, intentProfile, context);
grouped.SOL = rankUnitsForType(grouped.SOL, "SOL", topPerType, intentProfile, context);
return grouped;
}
function formatLinks(units) {
return units.length > 0 ? units.map((unit) => `[[${unit.basename}]]`).join("、") : "暂无";
}
function buildAssembly(args) {
const title = args.title;
let questionUnits = splitList(args.questions || args.question).map(loadUnit);
let conceptUnits = splitList(args.concepts || args.concept).map(loadUnit);
let opinionUnits = splitList(args.opinions || args.opinion).map(loadUnit);
let caseUnits = splitList(args.cases || args.case).map(loadUnit);
let solutionUnits = splitList(args.solutions || args.solution).map(loadUnit);
if (args.query || args.auto) {
const recommended = recommendUnitsByQuery(args.title, args.query || "", Number(args.top || 3));
if (questionUnits.length === 0) questionUnits = recommended.QST;
if (conceptUnits.length === 0) conceptUnits = recommended.CON;
if (opinionUnits.length === 0) opinionUnits = recommended.OPI;
if (caseUnits.length === 0) caseUnits = recommended.CAS;
if (solutionUnits.length === 0) solutionUnits = recommended.SOL;
}
if (questionUnits.length === 0) fail("至少提供 1 个问题单元:--question 或 --questions");
if (conceptUnits.length === 0) fail("至少提供 1 个概念单元:--concept 或 --concepts");
if (opinionUnits.length === 0) fail("至少提供 1 个观点单元:--opinion 或 --opinions");
if (caseUnits.length === 0) fail("至少提供 1 个案例单元:--case 或 --cases");
if (solutionUnits.length === 0) fail("至少提供 1 个方案单元:--solution 或 --solutions");
const mainQuestion = questionUnits[0];
const mainConcept = conceptUnits[0];
const mainOpinion = opinionUnits[0];
const mainCase = caseUnits[0];
const mainSolution = solutionUnits[0];
const audience =
args.audience ||
"对赚钱这件事有真实焦虑,但还没有把兴趣、需求、能力和业务接成一条完整路径的人";
const assemblyReason =
args.reason ||
`这组装配先用「${oneLine(mainQuestion.summary)}」界定问题,再用「${oneLine(mainOpinion.summary)}」给出判断边界,然后用案例把判断落地,最后用方案把下一步动作写清楚。`;
const closing =
args.closing ||
"把抽象的赚钱焦虑,改造成可以拆解、可以验证、可以继续重组的内容结构";
const lines = [
`# 选题装配:${title}`,
"",
"## 目标受众",
"",
audience,
"",
"## 装配理由",
"",
assemblyReason,
"",
"## 核心调用单元",
"",
"### 问题",
"",
`- [[${mainQuestion.basename}]]`,
"",
"### 概念",
"",
`- [[${mainConcept.basename}]]`,
"",
"### 观点",
"",
`- [[${mainOpinion.basename}]]`,
"",
"### 案例",
"",
`- [[${mainCase.basename}]]`,
"",
"### 方案",
"",
`- [[${mainSolution.basename}]]`,
"",
"## 可追加调用单元",
"",
`- 补充问题:${formatLinks(questionUnits.slice(1))}`,
`- 补充概念:${formatLinks(conceptUnits.slice(1))}`,
`- 补充观点:${formatLinks(opinionUnits.slice(1))}`,
`- 补充案例:${formatLinks(caseUnits.slice(1))}`,
`- 补充方案:${formatLinks(solutionUnits.slice(1))}`,
"",
"## 建议结构",
"",
`1. 痛点:${oneLine(mainQuestion.summary)}`,
`2. 冲突:${oneLine(mainOpinion.summary)}`,
`3. 展开:${oneLine(mainConcept.summary)}`,
`4. 案例:${oneLine(mainCase.summary)}`,
`5. 方法:${oneLine(mainSolution.summary)}`,
`6. 收束:${closing}`,
"",
"## 表达骨架",
"",
`### 开头\n\n${oneLine(mainQuestion.summary)}`,
"",
`### 中段 1\n\n${oneLine(mainConcept.summary)}`,
"",
`### 中段 2\n\n${oneLine(mainOpinion.summary)}`,
"",
`### 中段 3\n\n${oneLine(mainSolution.summary)}`,
"",
`### 结尾\n\n${closing}`,
"",
"## 备注",
"",
`- 来源单元:${[...questionUnits, ...conceptUnits, ...opinionUnits, ...caseUnits, ...solutionUnits]
.map((unit) => `[[${unit.basename}]]`)
.join("、")}`,
args.query ? `- 查询词:${args.query}` : null,
`- 生成时间:${new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date())}`,
].filter(Boolean);
ensureDir(assemblyRoot);
const datePrefix = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
const target = path.join(assemblyRoot, `${datePrefix}_${slugFromTitle(title)}_装配稿.md`);
fs.writeFileSync(target, lines.join("\n") + "\n");
console.log(target);
}
const args = parseArgs(process.argv.slice(2));
if (!args.title) fail("缺少标题:--title");
buildAssembly(args);
tools/extract-sample-units.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const stateRoot = path.join(root, "03-处理状态");
const sourceRoot = path.join(root, "01-原始素材区");
const unitRoot = path.join(root, "02-内容单元库");
const themeRoot = path.join(root, "05-主题地图");
const assemblyRoot = path.join(root, "06-选题装配");
const templateRoot = path.join(root, "04-模板");
const typeConfig = {
QST: { dir: "问题单元", template: "问题单元模板.md", typeName: "问题单元" },
CON: { dir: "概念单元", template: "概念单元模板.md", typeName: "概念单元" },
OPI: { dir: "观点单元", template: "观点单元模板.md", typeName: "观点单元" },
CAS: { dir: "案例单元", template: "案例单元模板.md", typeName: "案例单元" },
SOL: { dir: "方案单元", template: "方案单元模板.md", typeName: "方案单元" },
};
const ledgerCatalog = [
{ category: "短视频", sourceType: "短视频", dirs: ["短视频/文稿"] },
{ category: "公众号", sourceType: "公众号文章", dirs: ["公众号"] },
{ category: "观点与概念", sourceType: "观点与概念", dirs: ["观点与概念"] },
{ category: "爆款文稿", sourceType: "爆款文稿", dirs: ["爆款文稿"] },
{ category: "推文", sourceType: "推文素材", dirs: ["推文"] },
{ category: "其他作者", sourceType: "外部研究素材", dirs: ["其他作者"] },
{ category: "dontbesilent", sourceType: "本人内容", dirs: ["dontbesilent"] },
{ category: "完整副本", sourceType: "完整副本", dirs: ["完整副本"] },
];
function usage(exitCode = 0) {
console[exitCode === 0 ? "log" : "error"](
[
"用法:",
"node 07-脚本与工具/extract-sample-units.js --files <相对路径1,相对路径2,...> [--theme 主题] [--author 作者] [--date YYYYMMDD]",
"node 07-脚本与工具/extract-sample-units.js --plan '短视频/文稿/011.md,公众号/xxx.md'",
].join("\n")
);
process.exit(exitCode);
}
function parseArgs(argv) {
const result = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) result[key] = true;
else {
result[key] = next;
i += 1;
}
}
return result;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function moveFileToTrash(filePath, reasonDir) {
if (!fs.existsSync(filePath)) return;
const trashRoot = path.join(root, ".trash", `${new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date())}_${reasonDir}`);
const relative = path.relative(root, filePath);
const target = path.join(trashRoot, relative);
ensureDir(path.dirname(target));
fs.renameSync(filePath, target);
}
function extractIdFromFilename(filePath) {
return path.basename(filePath, ".md").split("_")[0] || "";
}
function readTextSafe(file) {
const ext = path.extname(file).toLowerCase();
if (![".md", ".txt", ".html", ".json", ".csv", ".jsonl"].includes(ext)) return "";
return fs.readFileSync(file, "utf8");
}
function walkLedgerFiles(dir) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) results.push(...walkLedgerFiles(full));
else if (entry.isFile() && /\.(md|txt|html|csv|json|jsonl)$/i.test(entry.name)) results.push(full);
}
return results;
}
function titleFromPath(relPath) {
return path.basename(relPath, path.extname(relPath)).replace(/[_-]+/g, " ").trim() || "未命名样本";
}
function slugFromTitle(title) {
const cleaned = title.replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
return cleaned || "未命名主题";
}
function csvEscape(value) {
return `"${String(value ?? "").replaceAll("\"", "\"\"")}"`;
}
function parseCsvLine(line) {
const cells = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (ch === "\"") {
if (inQuotes && line[i + 1] === "\"") {
current += "\"";
i += 1;
} else {
inQuotes = !inQuotes;
}
} else if (ch === "," && !inQuotes) {
cells.push(current);
current = "";
} else current += ch;
}
cells.push(current);
return cells;
}
function loadRegistry() {
const registryPath = path.join(stateRoot, "来源注册表.csv");
const candidatePath = path.join(stateRoot, "来源注册表_批量生成候选.csv");
const map = new Map();
for (const file of [registryPath, candidatePath]) {
if (!fs.existsSync(file)) continue;
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
for (const line of lines.slice(1)) {
const cells = parseCsvLine(line);
if (cells[1] && cells[0] && !map.has(cells[1])) map.set(cells[1], cells[0]);
}
}
return map;
}
function loadProcessedRows() {
const processedPath = path.join(stateRoot, "已处理清单.csv");
if (!fs.existsSync(processedPath)) return [["path", "status", "source_type", "notes"]];
return fs.readFileSync(processedPath, "utf8").split("\n").filter(Boolean).map(parseCsvLine);
}
function saveProcessedRows(rows) {
const processedPath = path.join(stateRoot, "已处理清单.csv");
fs.writeFileSync(processedPath, rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n");
}
function rebuildPendingLedger(processedSet) {
const rawIndexPath = path.join(stateRoot, "原始素材索引.csv");
const pendingPath = path.join(stateRoot, "待处理清单.csv");
const rawRows = [["path", "category"]];
const pendingRows = [["path", "status", "source_type", "notes"]];
for (const rule of ledgerCatalog) {
const files = rule.dirs
.flatMap((rel) => walkLedgerFiles(path.join(sourceRoot, rel)))
.map((file) => path.relative(sourceRoot, file).replaceAll(path.sep, "/"))
.sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
for (const rel of files) {
rawRows.push([rel, rule.category]);
if (!processedSet.has(rel)) pendingRows.push([rel, "待处理", rule.sourceType, ""]);
}
}
fs.writeFileSync(rawIndexPath, rawRows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n");
fs.writeFileSync(pendingPath, pendingRows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n");
return { rawCount: rawRows.length - 1, pendingCount: pendingRows.length - 1 };
}
function appendLog(lines) {
const logPath = path.join(stateRoot, "抽取日志.md");
const existing = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8").trimEnd() : "# 抽取日志";
fs.writeFileSync(logPath, `${existing}\n\n${lines.join("\n")}\n`);
}
function upsertStatusOverview(summary) {
const output = path.join(stateRoot, "处理状态总览.md");
const lines = [
"# 处理状态总览",
"",
`最后更新:${summary.today}`,
"",
"## 当前范围",
"",
...summary.scope.map((item) => `- ${item}`),
"",
"## 当前已完成",
"",
...summary.done.map((item) => `- ${item}`),
"",
"## 当前未完成",
"",
...summary.todo.map((item) => `- ${item}`),
"",
"## 下一步",
"",
...summary.next.map((item) => `- ${item}`),
];
fs.writeFileSync(output, lines.join("\n") + "\n");
}
function nextId(prefix, dateText) {
if (!nextId.cache) nextId.cache = new Map();
const cacheKey = `${prefix}-${dateText}`;
if (nextId.cache.has(cacheKey)) {
const current = nextId.cache.get(cacheKey) + 1;
nextId.cache.set(cacheKey, current);
return `${prefix}-${dateText}-${String(current).padStart(3, "0")}`;
}
const dir = path.join(unitRoot, typeConfig[prefix].dir);
ensureDir(dir);
const existing = fs.readdirSync(dir).filter((name) => name.startsWith(`${prefix}-${dateText}-`) && name.endsWith(".md"));
let max = 0;
for (const name of existing) {
const match = name.match(new RegExp(`^${prefix}-${dateText}-(\\d{3})_`));
if (match) max = Math.max(max, Number(match[1]));
}
const next = max + 1;
nextId.cache.set(cacheKey, next);
return `${prefix}-${dateText}-${String(next).padStart(3, "0")}`;
}
function splitLines(text) {
return removeFrontmatter(text).replace(/\r\n/g, "\n").split("\n");
}
function splitParagraphs(text) {
return removeFrontmatter(text)
.replace(/\r\n/g, "\n")
.split(/\n{2,}/)
.map((item) => item.replace(/\n/g, " ").trim())
.filter((item) => item.length >= 16);
}
function removeFrontmatter(text) {
const normalized = String(text || "").replace(/\r\n/g, "\n");
return normalized.replace(/^---\n[\s\S]*?\n---\n*/, "");
}
function stripMarkdown(text) {
return removeFrontmatter(text)
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/^#{1,6}\s*/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function countMatches(text, pattern) {
return (String(text || "").match(pattern) || []).length;
}
function fileBaseName(relPath) {
return path.basename(relPath, path.extname(relPath));
}
function normalizeCandidateTitleText(text, max = 28) {
const normalized = stripMarkdown(String(text || ""))
.replace(/\*\*原链接\*\*[::].*$/gi, "")
.replace(/原链接[::].*$/gi, "")
.replace(/https?:\/\/\S+/gi, "")
.replace(/#\S+/g, "")
.replace(/[||].*$/g, "")
.replace(/\s+/g, " ")
.trim();
return truncateText(normalized, max);
}
function classifySource(relPath, rawText) {
const ext = path.extname(relPath).toLowerCase();
const normalized = String(rawText || "");
const stripped = stripMarkdown(normalized);
const basename = fileBaseName(relPath);
const lineCount = splitLines(normalized).length;
const paragraphCount = splitParagraphs(normalized).length;
const headingCount = countMatches(normalized, /^#{1,6}\s+/gm);
const bulletCount = countMatches(normalized, /^\s*(?:[-*+]\s+|\d+\.\s+)/gm);
const hasJsonSignals = /"[^"]+"\s*:/.test(normalized);
const hasCsvSignals = ext === ".csv" || /^".+?",".+?"/m.test(normalized);
const hasUrlDensity = countMatches(normalized, /https?:\/\//g) >= 5;
const hasTweetMarkers = /tweet|推文|转发|回复|主贴|thread/i.test(`${relPath}\n${normalized}`);
const hasBatchSignals = /batch_\d+|annotated|cleaned|analysis report|content library|insights collection|quality report/i.test(
`${relPath}\n${normalized}`
);
const hasMissingTextSignals = /missing_text|缺正文推文清单|来源:quality_report/i.test(`${relPath}\n${normalized}`);
const hasReadableLongform = headingCount >= 2 || paragraphCount >= 6 || bulletCount >= 8;
const numberedShortDraft = /^\d{3}$/.test(basename) && relPath.includes("/短视频/文稿/");
if (ext === ".json" || ext === ".jsonl" || hasJsonSignals) {
return {
kind: "skip",
reason: "结构化中间文件",
confidence: "high",
};
}
if (hasCsvSignals) {
return {
kind: "skip",
reason: "表格索引文件",
confidence: "high",
};
}
if (/README\.md$/i.test(relPath) || /^README$/i.test(basename)) {
return {
kind: "skip",
reason: "说明文件",
confidence: "high",
};
}
if (hasBatchSignals) {
return {
kind: "skip",
reason: "推文处理中间产物",
confidence: "high",
};
}
if (hasMissingTextSignals) {
return {
kind: "skip",
reason: "缺正文索引或质量报告",
confidence: "high",
};
}
if (hasTweetMarkers && (hasUrlDensity || lineCount > 400 || paragraphCount > 40)) {
return {
kind: "normalize-tweet-archive",
reason: "推文合集或导出长卷",
confidence: "high",
};
}
if (numberedShortDraft && stripped.length >= 80) {
return {
kind: "extract-short-draft",
reason: "编号短视频文稿",
confidence: "medium",
};
}
if (hasReadableLongform && stripped.length >= 400) {
return {
kind: "extract-article",
reason: "结构较完整的成稿",
confidence: "high",
};
}
if (stripped.length >= 120 && paragraphCount >= 2) {
return {
kind: "extract-short-draft",
reason: "可抽取短稿",
confidence: "medium",
};
}
return {
kind: "skip",
reason: "信息密度不足或不适合直接抽取",
confidence: "medium",
};
}
function normalizeTweetArchive(relPath, rawText) {
const bodyMatches = [...removeFrontmatter(rawText).matchAll(/正文:\s*\n\n([\s\S]*?)(?=\n---\n|\n###\s+\d+\.|\n##\s+|$)/g)]
.map((match) => cleanSentence(match[1]))
.filter((item) => item && item.length >= 18);
if (bodyMatches.length > 0) {
return dedupeBy(bodyMatches, (item) => item)
.slice(0, 12)
.map((item, index) => {
const title = normalizeCandidateTitleText(takeFirstSentence(item) || `${fileBaseName(relPath)} 第 ${index + 1} 段`);
return {
relPath: `${relPath}#chunk-${String(index + 1).padStart(2, "0")}`,
title,
text: item,
normalizedTitle: title,
primaryTheme: normalizeCandidateTitleText(takeFirstSentence(item) || "推文", 18),
sourceType: "推文素材",
};
});
}
const lines = splitLines(rawText)
.map((line) => line.trim())
.filter((line) => line && !/^原链接[::]/i.test(line) && !/^\d{4}-\d{2}-\d{2}/.test(line) && !/^来源[::]/.test(line));
const paragraphs = splitParagraphs(rawText);
const longParagraphs = paragraphs.filter(
(item) =>
stripMarkdown(item).length >= 60 &&
!/^原链接[::]/i.test(item) &&
!/^\d{4}-\d{2}-\d{2}/.test(item) &&
!/^来源[::]/.test(item)
);
const tweetLikeLines = lines.filter((line) => stripMarkdown(line).length >= 30 && !/^https?:\/\//i.test(line));
const chunks = dedupeBy([...longParagraphs, ...tweetLikeLines], (item) => stripMarkdown(item))
.slice(0, 12)
.map((item, index) => ({
relPath: `${relPath}#chunk-${String(index + 1).padStart(2, "0")}`,
title: normalizeCandidateTitleText(takeFirstSentence(item) || `${fileBaseName(relPath)} 第 ${index + 1} 段`),
text: item,
normalizedTitle: normalizeCandidateTitleText(takeFirstSentence(item) || `${fileBaseName(relPath)} 第 ${index + 1} 段`),
primaryTheme: normalizeCandidateTitleText(takeFirstSentence(item) || "推文", 18),
sourceType: "推文素材",
}));
return chunks;
}
function cleanSentence(text) {
return stripMarkdown(text).replace(/[。;:]+$/, "").trim();
}
function normalizeKeywords(items) {
const result = [];
for (const item of items) {
const normalized = stripMarkdown(String(item || "")).replace(/[,、]/g, " ").trim();
if (!normalized) continue;
for (const token of normalized.split(/\s+/)) {
const clean = token.trim();
if (!clean || clean.length < 2) continue;
if (!result.includes(clean)) result.push(clean);
if (result.length >= 8) return result;
}
}
return result;
}
function takeFirstSentence(paragraph) {
const text = stripMarkdown(paragraph);
const parts = text.split(/[。!?!?]/).map((item) => item.trim()).filter(Boolean);
return (parts[0] || text).trim();
}
function removeLeadingSerial(text) {
return stripMarkdown(text).replace(/^\d+[.\-_\s、]+/, "").trim();
}
function ensureQuestion(text) {
const normalized = cleanSentence(text).replace(/[??!!。]+$/g, "").trim();
if (!normalized) return "";
return `${normalized}?`;
}
function truncateText(text, max = 120) {
const normalized = stripMarkdown(text);
if (normalized.length <= max) return normalized;
return `${normalized.slice(0, max).trim()}……`;
}
function dedupeBy(items, getKey) {
const map = new Map();
for (const item of items) {
const key = getKey(item);
if (!key || map.has(key)) continue;
map.set(key, item);
}
return [...map.values()];
}
function buildBulletBody(lines) {
return lines.filter(Boolean).map((line) => `- ${line}`).join("\n");
}
function summarizeList(items, max = 3) {
const cleaned = items.map((item) => cleanSentence(item)).filter(Boolean);
return cleaned.slice(0, max).join(";");
}
function collectSection(lines, heading) {
const startIndex = lines.findIndex((line) => line.trim() === heading);
if (startIndex === -1) return "";
const collected = [];
for (let i = startIndex + 1; i < lines.length; i += 1) {
const line = lines[i];
if (/^##\s+/.test(line.trim())) break;
collected.push(line);
}
return collected.join("\n").trim();
}
function findParagraph(paragraphs, pattern) {
return paragraphs.find((item) => pattern.test(item)) || "";
}
function parseBullets(text) {
const lines = splitLines(text);
const result = [];
for (const line of lines) {
const trimmed = line.trim();
const bulletMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (bulletMatch) {
result.push(cleanSentence(bulletMatch[1]));
continue;
}
const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (orderedMatch) {
result.push(cleanSentence(orderedMatch[1]));
continue;
}
}
return result.filter(Boolean);
}
function parseInlineField(text, label) {
const pattern = new RegExp(`\\*\\*${label}:\\*\\*\\s*([^\\n]+)`);
const match = text.match(pattern);
return match ? cleanSentence(match[1]) : "";
}
function summarizeThreePartClaim(text) {
const match = text.match(/满足三个条件[::]?([\s\S]*?)$/);
if (!match) return "";
const fragment = cleanSentence(match[1]);
if (!fragment) return "";
return `兴趣变现要同时满足三件事:${fragment}`;
}
function inferTheme(relPath, providedTheme, context) {
if (providedTheme) return providedTheme;
if (context.primaryTheme) return context.primaryTheme;
const parts = relPath.split("/");
if (parts.length >= 2) {
const candidate = parts[parts.length - 2].replace(/[_-]+/g, " ").trim();
if (candidate && !/^(文稿|推文|cleaned|完整副本)$/i.test(candidate)) return candidate;
}
if (context.normalizedTitle && !/^\d{3}$/.test(context.normalizedTitle)) return context.normalizedTitle;
return "待补主题";
}
function inferSourceType(relPath) {
if (relPath.includes("推文")) return "推文素材";
if (relPath.includes("短视频")) return "短视频";
if (relPath.includes("公众号")) return "公众号文章";
if (relPath.includes("爆款文稿")) return "爆款文稿";
if (relPath.includes("观点与概念")) return "观点与概念";
if (relPath.includes("其他作者")) return "外部研究素材";
return "原始素材";
}
function detectQuestionType(text) {
if (/为什么|本质|根本|误区|错在/.test(text)) return "认知问题";
if (/怎么|如何|怎样|步骤|路径|开始|落地/.test(text)) return "方法问题";
return "待人工复核";
}
function buildKeywords(title, context, extra = []) {
const merged = [
title,
context.coreTheme,
...(context.keywords || []),
...(context.applicableScenes || []),
...extra,
];
const keywords = normalizeKeywords(merged);
return keywords.length > 0 ? keywords.slice(0, 8) : ["待补关键词"];
}
function extractStructuredContext(text, title) {
const lines = splitLines(text);
const summarySection = collectSection(lines, "## 核心观点提炼");
const videoSection = collectSection(lines, "## 视频文稿");
const scenarioSection = collectSection(lines, "## 适用场景");
const definitionSection = collectSection(lines, "## 概念定义");
const conceptSection = collectSection(lines, "## 核心观点");
const examplesSection = collectSection(lines, "## 使用示例");
const context = {
title,
normalizedTitle: removeLeadingSerial(title),
titleQuestion: title.includes("如何") || title.includes("为什么") ? title : "",
videoSummary: splitParagraphs(videoSection),
summaryParagraphs: splitParagraphs(summarySection),
applicableScenes: parseBullets(scenarioSection),
conceptDefinitionSection: splitParagraphs(definitionSection),
conceptViewSection: splitParagraphs(conceptSection),
examplesSection: splitParagraphs(examplesSection),
coreTheme: parseInlineField(text, "核心主题"),
keywords: normalizeKeywords([parseInlineField(text, "关键词")]),
dataPoint: parseInlineField(text, "视频数据"),
};
const structuredBullets = parseBullets(summarySection);
context.structuredBullets = structuredBullets;
context.primaryTheme = context.coreTheme.split(/[、,,]/).map((item) => cleanSentence(item)).find(Boolean) || "";
const numberedBlocks = [...summarySection.matchAll(/###\s+(.+?)\n([\s\S]*?)(?=\n###\s+|\s*$)/g)];
context.summaryBlocks = numberedBlocks.map((match) => ({
heading: cleanSentence(match[1]),
body: cleanSentence(match[2]),
bullets: parseBullets(match[2]),
}));
const questionCandidate = context.titleQuestion || findParagraph(context.videoSummary, /如何|为什么|怎么|能不能|是不是/);
context.questionText = cleanSentence(questionCandidate || `这篇内容试图回答什么问题:${title}`);
const threePartBlock = context.summaryBlocks.find((item) => /兴趣变现三要素/.test(item.heading));
const opinionCandidate =
summarizeThreePartClaim(findParagraph(context.videoSummary, /满足三个条件|满足三件事/)) ||
cleanSentence(
threePartBlock
? `兴趣变现要同时满足三件事:${threePartBlock.bullets
.filter((item) => /生产型兴趣|匹配的能力|具体的业务/.test(item))
.join(";")}`
: ""
) ||
context.summaryParagraphs.find((item) => /我认为|关键|本质|核心|正确/.test(item)) ||
findParagraph(context.videoSummary, /我认为|关键|本质|不是|一定要/);
context.opinionText = cleanSentence(opinionCandidate || `${title} 对应的核心判断待人工补全`);
const conceptDefinitionParagraph =
context.conceptDefinitionSection[0] ||
findParagraph(context.videoSummary, /生产型兴趣|消费型兴趣/) ||
"";
const conceptCandidate = cleanSentence(
conceptDefinitionParagraph
? "生产型兴趣是能持续产出价值并进入市场交换的兴趣;消费型兴趣只消耗金钱和时间,不产生可交易产出"
: ""
);
context.conceptText = conceptCandidate || `${title} 中出现了需要稳定定义的概念,待人工补全`;
context.conceptFunction = cleanSentence(
context.conceptViewSection[0] ||
"用于区分什么样的兴趣能转化成业务,以及为什么仅有兴趣本身还不够"
);
const caseSummaryCandidate =
findParagraph(context.videoSummary, /一万两千条|一万粉丝|一千六百万|十二万|答疑群|小红书|抖音/) ||
context.examplesSection[0] ||
"";
context.caseSummary = cleanSentence(
caseSummaryCandidate ||
"把商业问题想清楚、写清楚、说清楚,先换数据,再换产品,再换流量和商业模式"
);
context.caseProcess = cleanSentence(
findParagraph(context.videoSummary, /首先我做的第一个事情|这个时候我就马上意识到|所以接下来|基于此/) ||
"先公开输出换数据,再把数据转成产品,再把产品和能力转成流量与业务"
);
context.caseResult = cleanSentence(
findParagraph(context.videoSummary, /一万粉丝|一千六百万次|十二万|交易过的人没有亏过|线上加线下/) ||
"验证了兴趣可以通过能力和具体业务被持续兑换成钱"
);
const stepBullets = structuredBullets.filter((item) => /^第|兴趣→|数据→|产品→|能力→|流量→/.test(item) || /步骤|路径|测试|迭代/.test(item));
const explicitSteps = stepBullets.length > 0 ? stepBullets : parseBullets(videoSection).slice(0, 5);
const solutionCandidate =
context.summaryBlocks.find((item) => /步骤|路径|方法|三要素/.test(item.heading))?.body ||
explicitSteps[0] ||
context.videoSummary.find((item) => /第一|第二|第三|步骤|路径/.test(item)) ||
"";
context.solutionSummary = cleanSentence(solutionCandidate || `${title} 对应的方案摘要待人工补全`);
context.actionSteps = explicitSteps.length > 0 ? explicitSteps.slice(0, 6) : ["待人工补全步骤 1"];
context.expectedResult = cleanSentence(
findParagraph(context.videoSummary, /然后你就能把你的兴趣变成钱|构成了我现在的线上加线下|实现可持续的规模增长/) ||
"最终把兴趣、能力和具体业务接上,形成可持续增长的现金流"
);
context.relatedScenes = context.applicableScenes.length > 0 ? context.applicableScenes.slice(0, 6) : ["待人工补全"];
return context;
}
function extractSimpleContext(text, title, relPath, profile) {
const rawNormalizedTitle = normalizeCandidateTitleText(removeLeadingSerial(title));
const stripped = stripMarkdown(text);
const paragraphs = splitParagraphs(text);
const lines = splitLines(text).map((item) => item.trim()).filter(Boolean);
const sentences = stripped
.split(/[。!?!?]/)
.map((item) => item.trim())
.filter((item) => item.length >= 12);
const inferredTitle =
sentences.find((item) => item.length >= 14 && !/^(所以|但是|然后|如果|因为|你要|你得|我们|就是)/.test(item)) ||
paragraphs.find((item) => stripMarkdown(item).length >= 14) ||
rawNormalizedTitle;
const normalizedTitle = /^\d{3}$/.test(rawNormalizedTitle)
? normalizeCandidateTitleText(inferredTitle) || "待人工聚类短稿"
: rawNormalizedTitle;
const firstSentence = sentences[0] || stripped.slice(0, 80);
const questionLine =
sentences.find((item) => /为什么|如何|怎么|能不能|是不是|是否/.test(item)) ||
(profile.kind === "extract-short-draft" ? `${normalizedTitle} 这条文稿试图说明什么问题` : `${normalizedTitle} 这篇内容试图回答什么问题`);
const opinionLine =
sentences.find((item) => /本质|核心|关键|不是|而是|必须|应该|不要|先/.test(item)) ||
firstSentence;
const conceptLine =
sentences.find((item) => /是指|就是|意味着|区别|本质是/.test(item)) ||
opinionLine;
const exampleLine =
sentences.find((item) => /\d+|案例|比如|例如|有人|一次|后来|结果/.test(item)) ||
firstSentence;
const actionBullets = lines
.filter((item) => /先|再|然后|最后|第一|第二|第三|步骤|动作|可以/.test(item))
.slice(0, 4)
.map((item) => cleanSentence(item));
const fallbackSteps =
actionBullets.length > 0
? actionBullets
: sentences
.filter((item) => /先|再|然后|最后/.test(item))
.slice(0, 3)
.map((item) => cleanSentence(item));
const sourceType = relPath.includes("/短视频/") ? "短视频" : relPath.includes("/推文/") ? "推文素材" : inferSourceType(relPath);
const primaryTheme =
relPath.includes("/短视频/") ? normalizedTitle :
relPath.includes("/推文/") ? "推文" :
normalizedTitle || "待补主题";
return {
title,
normalizedTitle,
titleQuestion: /如何|为什么|怎么|能不能|是不是/.test(normalizedTitle) ? normalizedTitle : "",
videoSummary: paragraphs,
summaryParagraphs: paragraphs,
applicableScenes: [primaryTheme],
conceptDefinitionSection: [conceptLine],
conceptViewSection: [opinionLine],
examplesSection: [exampleLine],
coreTheme: primaryTheme,
keywords: buildKeywords(normalizedTitle, { coreTheme: primaryTheme, keywords: [], applicableScenes: [primaryTheme] }),
dataPoint: "",
structuredBullets: fallbackSteps,
primaryTheme,
summaryBlocks: [],
questionText: cleanSentence(questionLine),
opinionText: cleanSentence(opinionLine),
conceptText: cleanSentence(conceptLine),
conceptFunction: "用于把短稿里的核心判断固定成可复用节点",
caseSummary: cleanSentence(exampleLine),
caseProcess: cleanSentence(sentences[1] || exampleLine),
caseResult: cleanSentence(sentences[2] || exampleLine),
solutionSummary: cleanSentence(fallbackSteps[0] || firstSentence),
actionSteps: fallbackSteps.length > 0 ? fallbackSteps : ["待人工复核后补全步骤"],
expectedResult: "把短稿里的核心判断固定成可复用节点,并保留后续人工复核入口",
relatedScenes: [primaryTheme],
sourceType,
extractionMode: profile.kind,
extractionReason: profile.reason,
};
}
function findSummaryBlock(context, pattern) {
return context.summaryBlocks.find((item) => pattern.test(item.heading));
}
function findVideoParagraph(context, pattern) {
return context.videoSummary.find((item) => pattern.test(item)) || "";
}
function buildMainQuestion(context) {
if (/生产型兴趣/.test(context.conceptText) && /兴趣/.test(context.normalizedTitle) && /赚钱|变现/.test(context.normalizedTitle)) {
return "什么样的兴趣属于可以变现的生产型兴趣,以及怎样把兴趣、能力和具体业务接起来,做成可持续增长?";
}
if (/赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)) {
return "怎样在不陷入赛道思维的前提下,快速找到一个可以从零到一测试并盈利的具体生意?";
}
if (isAntifragileIncomeContext(context)) {
return "为什么看起来稳定的工资收入,本质上是在用增长空间和风险承担能力去交换确定性,以及怎样把自己练成反脆弱状态?";
}
if (isPassiveIncomeSystemContext(context)) {
return "被动收入到底是什么,怎样把线性的人力赚钱改造成一个可维护、可放量、还能逐步融入兴趣的赚钱系统?";
}
if (isBossMindsetContext(context)) {
return "为什么很多人离开公司之后仍然赚不到钱,以及怎样把从能力出发的员工思维改成从需求倒推的老板思维?";
}
if (context.normalizedTitle && /如何|为什么|怎么|能不能|是不是/.test(context.normalizedTitle)) {
return ensureQuestion(context.normalizedTitle);
}
return ensureQuestion(context.questionText || `${context.normalizedTitle} 这件事到底该怎么处理`);
}
function isIndustryStrategyContext(context) {
return /赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`);
}
function isAntifragileIncomeContext(context) {
return /稳定收入|保险费|反脆弱|工资的本质|不确定性/.test(`${context.coreTheme} ${context.normalizedTitle}`);
}
function isPassiveIncomeSystemContext(context) {
return /被动收入|系统赚钱|自增长长尾流量|兴趣融入业务/.test(
`${context.coreTheme} ${context.normalizedTitle} ${textifyContext(context)}`
);
}
function isBossMindsetContext(context) {
return /员工思维|老板思维|从右到左|需求倒推|目的vs手段|找对标/.test(
`${context.coreTheme} ${context.normalizedTitle} ${textifyContext(context)}`
);
}
function buildMainOpinion(context) {
if (/生产型兴趣/.test(context.conceptText)) {
return "兴趣能不能变现,不取决于兴趣这个标签本身,而取决于三件事:它是不是生产型兴趣、有没有与之匹配的能力、有没有落到具体业务里";
}
if (/赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)) {
return "找生意时不要先问某个行业能不能做,而要先拆一个具体生意的内容、流量、变现和产品组合,再用短周期测试验证自己能不能做";
}
if (isAntifragileIncomeContext(context)) {
return "工资的本质不是单纯的劳动报酬,而是把业务波动和不确定性打包转交给雇主之后,为自己购买稳定性的一份保险";
}
if (isPassiveIncomeSystemContext(context)) {
return "被动收入不是你什么都不干,而是资金流入和时间支出不再线性相关;真正关键的是把人赚钱改造成系统赚钱,再把兴趣逐步融入系统里";
}
if (isBossMindsetContext(context)) {
return "赚不到钱的根本原因,往往不是能力不够,而是思维顺序错了;员工从能力出发,老板从需求出发,再倒推产品、内容和所需能力";
}
return cleanSentence(context.opinionText || `${context.normalizedTitle} 对应的核心判断待补全`);
}
function buildThreePartSteps(context) {
if (/生产型兴趣/.test(context.conceptText)) {
return [
"先区分生产型兴趣和消费型兴趣,确认这件事能不能持续产出可交易价值",
"再补齐与兴趣匹配的能力,例如写作、理解用户和升维解释问题",
"最后把兴趣和能力放进一个具体业务,明确产品、渠道和成交方式",
];
}
if (/赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)) {
return [
"先找一个已经有人赚到钱的具体生意,不要先盯行业和赛道标签",
"再把这个生意拆成内容、流量、变现、产品几个维度,看清它的组合方式",
"最后给自己半个月到一个月去测试,验证自己能不能做,并根据结果调整下一次选择",
];
}
if (isAntifragileIncomeContext(context)) {
return [
"先识别自己为了稳定性支付了哪些隐性保险费,例如工资折价、负债束缚和选择权流失",
"再为自己准备反脆弱底盘,例如 6 到 12 个月储备金、避免高杠杆、持续跨学科学习",
"最后把收入目标从固定数值改成与价值创造挂钩,训练自己和波动性共处",
];
}
if (isPassiveIncomeSystemContext(context)) {
return [
"先承认被动收入不是不干活,而是先搭出一个能替你承接流量、成交和交付的赚钱系统",
"再判断这个系统处在哪一层:零投入分佣、可放量分销、可推高天花板的产品,还是自增长长尾流量",
"最后把你的兴趣和长期愿意持续做的内容融进去,让维护系统这件事本身也变成你愿意主动做的事情",
];
}
if (isBossMindsetContext(context)) {
return [
"先从用户需求出发,判断什么内容和产品组合已经被市场验证能赚钱",
"再倒推要解决哪些环节,自己不会的能力就去学、去雇人或者去找合作",
"最后用对标持续校正内容和产品的匹配,而不是困在我会什么技能这类左到右的问题里",
];
}
return context.actionSteps.slice(0, 3);
}
function buildAudience(context) {
if (isPassiveIncomeSystemContext(context)) {
return "已经不满足于按小时换钱,想把收入改造成系统性现金流,同时希望把兴趣和内容能力融进业务的人";
}
if (isBossMindsetContext(context)) {
return "有技能、有执行力,但一开口总是在问我的能力怎么变现,而不是先看需求和对标的人";
}
if (isIndustryStrategyContext(context)) {
return "正在找从零到一生意方向,但总是被赛道、行业和机会清单绕住的人";
}
if (isAntifragileIncomeContext(context)) {
return "表面追求稳定收入,实际已经感受到工资、负债和确定性依赖正在限制自己的人";
}
return cleanSentence(
context.relatedScenes[0] ||
context.summaryParagraphs.find((item) => /适合|适用|讨论/.test(item)) ||
"对这个问题有真实需求、并准备把旧内容重组为新表达的人"
);
}
function buildClosingLine(context) {
if (isPassiveIncomeSystemContext(context)) {
return "把一次性体力活,改造成一个能被维护、被放大、还能承接兴趣的赚钱系统";
}
if (isBossMindsetContext(context)) {
return "把我会什么改成用户要什么,你的内容、产品和赚钱路径才会真正连起来";
}
return "把原本抽象的问题,改造成可以逐项检查、持续增长的内容结构";
}
function buildPathSteps(context) {
const keyPathBlock = findSummaryBlock(context, /关键路径|增长路径|执行路径/);
if (keyPathBlock && keyPathBlock.bullets.length > 0) return keyPathBlock.bullets.slice(0, 6);
const arrowSteps = (context.actionSteps || []).filter((item) => /→|路径|第一|第二|第三/.test(item));
if (arrowSteps.length >= 3) return arrowSteps.slice(0, 6);
return [];
}
function pickPrimaryCandidate(candidates, preferredKey) {
return candidates.find((item) => item.key === preferredKey) || candidates[0] || null;
}
function buildSemanticCandidates(context, theme) {
const sharedUsage = context.relatedScenes;
const candidates = {
QST: [],
CON: [],
OPI: [],
CAS: [],
SOL: [],
};
const mainQuestion = buildMainQuestion(context);
candidates.QST.push({
key: "qst-main",
title: /生产型兴趣/.test(context.conceptText)
? "什么样的兴趣能真正变现"
: isPassiveIncomeSystemContext(context)
? "怎样把人赚钱改造成系统赚钱"
: isBossMindsetContext(context)
? "为什么能力强的人离开公司后仍然赚不到钱"
: `${context.normalizedTitle} 的核心问题`,
questionText: mainQuestion,
questionType: detectQuestionType(mainQuestion),
applicableTopics: [theme, ...sharedUsage.slice(0, 3)].filter(Boolean),
bodyText: [
`核心问题:${mainQuestion}`,
isIndustryStrategyContext(context)
? "拆解边界:先停止用行业标签提问,再拆一个具体生意的内容、流量、变现和产品组合,最后用短周期测试验证自己能不能做。"
: isAntifragileIncomeContext(context)
? "拆解边界:先看稳定收入背后付出的保险费,再看负债和固定成本怎样抽走了你的能量,最后看怎样把自己训练成能和波动共处的反脆弱状态。"
: isPassiveIncomeSystemContext(context)
? "拆解边界:先区分一次性赚钱和系统赚钱,再比较不同系统层级的放量能力与天花板,最后判断怎样把兴趣融进系统。"
: isBossMindsetContext(context)
? "拆解边界:先把问题起点从我的能力改成用户需求,再用对标验证内容与产品的匹配,最后倒推自己该补哪一环。"
: "拆解边界:先判断它是不是生产型兴趣,再判断有没有匹配能力,最后判断有没有落到具体业务里。",
].join("\n\n"),
usageScenarios: sharedUsage,
});
if (isIndustryStrategyContext(context)) {
candidates.QST.push({
key: "qst-wrong-question",
title: "为什么不能先问某某行业怎么做",
questionText: "为什么「某某行业怎么做」是一个错误问题,而不是一个能直接帮你找到生意的问题?",
questionType: "认知问题",
applicableTopics: [theme, ...sharedUsage.slice(0, 2)].filter(Boolean),
bodyText: [
"核心问题:为什么「某某行业怎么做」是一个错误问题,而不是一个能直接帮你找到生意的问题?",
"拆解焦点:行业内部会分化出大量具体生意路径,真正需要判断的是哪条路径适合你测试,而不是先研究整条赛道。",
].join("\n\n"),
usageScenarios: sharedUsage,
});
}
if (isPassiveIncomeSystemContext(context)) {
candidates.QST.push({
key: "qst-passive-income-definition",
title: "被动收入不是啥都不干",
questionText: "为什么被动收入不是啥都不干,而是资金流入和时间支出不再线性相关?",
questionType: "认知问题",
applicableTopics: [theme, ...sharedUsage.slice(0, 2)].filter(Boolean),
bodyText: [
"核心问题:为什么被动收入不是啥都不干,而是资金流入和时间支出不再线性相关?",
"拆解焦点:不是追求彻底不劳动,而是先搭一个系统,让系统替你承接一部分流量、成交和交付,你负责维护和升级。",
].join("\n\n"),
usageScenarios: sharedUsage,
});
}
if (isBossMindsetContext(context)) {
candidates.QST.push({
key: "qst-demand-before-skill",
title: "为什么不能从我的能力怎么变现开始问",
questionText: "为什么「我有这个能力,怎么把它变现」通常是一个会把人带回打工思维的问题?",
questionType: "认知问题",
applicableTopics: [theme, ...sharedUsage.slice(0, 2)].filter(Boolean),
bodyText: [
"核心问题:为什么「我有这个能力,怎么把它变现」通常会把人带回打工思维?",
"拆解焦点:因为它默认先有技能再去找需求,思路是从左到右,而真正能创业赚钱的路径是先有需求,再倒推产品、内容和能力。",
].join("\n\n"),
usageScenarios: sharedUsage,
});
}
const whyBlock = findSummaryBlock(context, /为什么/);
if (whyBlock) {
const whyQuestion = ensureQuestion(whyBlock.heading);
candidates.QST.push({
key: "qst-why-block",
title: removeLeadingSerial(whyBlock.heading),
questionText: whyQuestion,
questionType: detectQuestionType(whyQuestion),
applicableTopics: [theme, ...sharedUsage.slice(0, 2)].filter(Boolean),
bodyText: [
`核心问题:${whyQuestion}`,
`拆解焦点:${whyBlock.bullets.join(";") || cleanSentence(whyBlock.body)}`,
].join("\n\n"),
usageScenarios: sharedUsage,
});
}
const conceptTitle = /生产型兴趣/.test(context.conceptText)
? "生产型兴趣与消费型兴趣的区别"
: /赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)
? "具体生意与赛道思维的区别"
: isAntifragileIncomeContext(context)
? "工资稳定性与反脆弱的区别"
: isPassiveIncomeSystemContext(context)
? "被动收入与系统赚钱的区别"
: isBossMindsetContext(context)
? "员工思维与老板思维的区别"
: `${context.normalizedTitle} 的关键概念`;
candidates.CON.push({
key: "con-main",
title: conceptTitle,
conceptDefinition: /赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)
? "赛道思维是先按行业标签提问,再试图穷举行业里的所有做法;具体生意思维是先找到一个真实赚钱的业务闭环,再拆解它的内容、流量、变现和产品组合"
: isAntifragileIncomeContext(context)
? "稳定工资本质上是一种保险安排:个体把业务波动和风险转手给雇主,换取确定性的现金流;反脆弱则是在波动和不确定性出现时,能力、储备和选择权反而会被放大"
: isPassiveIncomeSystemContext(context)
? "被动收入不是完全不干活,而是收入和时间投入不再线性绑定;系统赚钱则是把原本靠人完成的获客、成交、交付,改造成一个可维护、可放量的系统"
: isBossMindsetContext(context)
? "员工思维是从自己会什么能力出发,再问能不能变现;老板思维是从用户需求出发,再倒推要做什么产品、内容和该找谁解决能力问题"
: context.conceptText,
conceptFunction: /赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)
? "用于解释为什么行业问题通常无效,以及为什么从零到一更适合先拆具体生意而不是先研究整条赛道"
: isAntifragileIncomeContext(context)
? "用于解释为什么稳定并不免费,以及为什么真正长期安全来自承受和利用波动的能力,而不是表面上的固定收入"
: isPassiveIncomeSystemContext(context)
? "用于区分做一笔就停的一次性收入,和能够持续承接现金流的赚钱系统,也用于解释为什么真正关键的是系统而不是被动两个字"
: isBossMindsetContext(context)
? "用于解释为什么很多能力很强的人一离开公司就赚不到钱,也用于解释内容、产品、需求和分工之间的正确连接顺序"
: context.conceptFunction,
bodyText: [
`概念定义:${
/赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)
? "赛道思维是先按行业标签提问,再试图穷举行业里的所有做法;具体生意思维是先找到一个真实赚钱的业务闭环,再拆解它的内容、流量、变现和产品组合"
: isAntifragileIncomeContext(context)
? "稳定工资本质上是一种保险安排:个体把业务波动和风险转手给雇主,换取确定性的现金流;反脆弱则是在波动和不确定性出现时,能力、储备和选择权反而会被放大"
: isPassiveIncomeSystemContext(context)
? "被动收入不是完全不干活,而是收入和时间投入不再线性绑定;系统赚钱则是把原本靠人完成的获客、成交、交付,改造成一个可维护、可放量的系统"
: isBossMindsetContext(context)
? "员工思维是从自己会什么能力出发,再问能不能变现;老板思维是从用户需求出发,再倒推要做什么产品、内容和该找谁解决能力问题"
: context.conceptText
}`,
`解释作用:${
/赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)
? "用于解释为什么行业问题通常无效,以及为什么从零到一更适合先拆具体生意而不是先研究整条赛道"
: isAntifragileIncomeContext(context)
? "用于解释为什么稳定并不免费,以及为什么真正长期安全来自承受和利用波动的能力,而不是表面上的固定收入"
: isPassiveIncomeSystemContext(context)
? "用于区分做一笔就停的一次性收入,和能够持续承接现金流的赚钱系统,也用于解释为什么真正关键的是系统而不是被动两个字"
: isBossMindsetContext(context)
? "用于解释为什么很多能力很强的人一离开公司就赚不到钱,也用于解释内容、产品、需求和分工之间的正确连接顺序"
: context.conceptFunction
}`,
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "解释", targetKey: "opi-main", note: "概念解释核心观点" },
{ type: "解释", targetKey: "qst-main", note: "概念解释主问题" },
],
});
const mainOpinion = buildMainOpinion(context);
candidates.OPI.push({
key: "opi-main",
title: /生产型兴趣/.test(context.conceptText)
? "兴趣变现不是看兴趣本身"
: /赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)
? "不要先问行业,先问具体生意"
: isAntifragileIncomeContext(context)
? "工资的本质是一份稳定性保险"
: isPassiveIncomeSystemContext(context)
? "被动收入的核心是系统赚钱"
: isBossMindsetContext(context)
? "先看需求,不要先看能力"
: `${context.normalizedTitle} 的核心观点`,
coreClaim: mainOpinion,
claimScope: cleanSentence(sharedUsage.join(";") || "适用于当前主题的核心论证场景"),
whyItMatters: "这条判断把一个空泛的大问题,改成了可以逐项核对的结构化问题。",
bodyText: [
`核心判断:${mainOpinion}`,
"判断价值:它可以直接用来筛掉无效兴趣、无效能力和没有业务承接的空想法。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "观点回应主问题" },
],
});
if (/赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)) {
candidates.OPI.push({
key: "opi-can-do-vs-you-can-do",
title: "能做不代表你能做",
coreClaim: "任何合法行业都有人赚钱,但行业能做不代表这个具体生意你现在就能做,真正该验证的是你的能力能不能撑起这条业务路径",
claimScope: cleanSentence(sharedUsage.join(";") || "适用于从零到一找生意与测试路径场景"),
whyItMatters: "这条判断把行业判断转成个人能力判断,直接决定测试方向。",
bodyText: [
"核心判断:任何合法行业都有人赚钱,但行业能做不代表这个具体生意你现在就能做。",
"判断价值:真正需要验证的不是行业本身,而是你的能力能不能撑起这条业务路径。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "观点补充主问题的判断边界" },
],
});
}
if (isAntifragileIncomeContext(context)) {
candidates.OPI.push({
key: "opi-stability-is-expensive",
title: "稳定是昂贵的奢侈品",
coreClaim: "真正昂贵的不是波动,而是稳定;越想让收入看起来旱涝保收,就越会在看不见的地方支付高额保险费和选择权成本",
claimScope: cleanSentence(sharedUsage.join(";") || "适用于解释稳定收入、负债约束和风险承受能力的场景"),
whyItMatters: "这条判断把稳定从美德改写成交易结果,能直接改变人对风险的理解。",
bodyText: [
"核心判断:真正昂贵的不是波动,而是稳定。",
"判断价值:一旦理解稳定是交易结果,就会开始计算自己为确定性支付了哪些代价。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "观点补充稳定性的代价" },
],
});
}
if (isPassiveIncomeSystemContext(context)) {
candidates.OPI.push({
key: "opi-interest-in-system",
title: "把兴趣融进系统才更持久",
coreClaim: "长期更优的状态,不是系统能赚钱但你厌烦维护,而是把兴趣融入内容和业务里,让维护系统这件事本身也变成你愿意主动做的事情",
claimScope: cleanSentence(sharedUsage.join(";") || "适用于内容业务、个人 IP 和长期被动收入系统设计场景"),
whyItMatters: "这条判断把赚钱系统从冷冰冰的自动机,推进到可以长期迭代的人和系统共生结构。",
bodyText: [
"核心判断:长期更优的状态,不是系统能赚钱但你厌烦维护,而是把兴趣融进内容和业务里。",
"判断价值:这样系统越做越像你自己,而不是越赚钱越像新的打工。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "观点补充主问题的长期目标" },
],
});
}
if (isBossMindsetContext(context)) {
candidates.OPI.push({
key: "opi-benchmark-proof",
title: "对标是内容和产品匹配的证据",
coreClaim: "对标的价值不是抄别人,而是用已经赚钱的样本证明:这样的内容和这样的产品确实能满足真实需求",
claimScope: cleanSentence(sharedUsage.join(";") || "适用于内容选题、产品设计和商业验证场景"),
whyItMatters: "这条判断把找对标从模糊方法论,变成了连接需求、内容和产品的验证工具。",
bodyText: [
"核心判断:对标的价值不是抄别人,而是验证内容和产品的匹配是否已经被市场证明。",
"判断价值:它能减少拍脑袋设计产品和内容的概率,让验证先于投入。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "观点补充验证路径" },
],
});
}
if (whyBlock) {
const secondaryClaim = /本质/.test(whyBlock.body) || whyBlock.bullets.some((item) => /本质/.test(item))
? "答疑群难做的本质,不是产品形态有问题,而是运营者缺少把答疑沉淀为内容、理解用户并升维解释问题的能力"
: cleanSentence(whyBlock.body);
candidates.OPI.push({
key: "opi-why-block",
title: removeLeadingSerial(whyBlock.heading),
coreClaim: secondaryClaim,
claimScope: cleanSentence(sharedUsage.join(";") || "适用于答疑、咨询和内容沉淀场景"),
whyItMatters: "这条判断解释了为什么同一种业务形态,换不同的人来做,结果会完全不同。",
bodyText: [
`核心判断:${secondaryClaim}`,
`展开依据:${whyBlock.bullets.join(";") || cleanSentence(whyBlock.body)}`,
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-why-block", note: "观点回应补充问题" },
],
});
}
const publicOutputParagraph = findVideoParagraph(context, /一万两千条|1\.2万|一万粉丝|1万粉丝|推文/);
if (publicOutputParagraph) {
candidates.CAS.push({
key: "cas-public-output",
title: "公开输出先换到数据验证",
caseSubject: "把生产型兴趣先公开输出,再用数据验证价值",
caseSummary: "先不急着做产品,而是把高价值判断公开发出去,用持续输出换到第一批数据反馈。",
caseProcess: "不把内容留在私密笔记里,而是连续公开发布推文;先观察有没有人点赞、关注、转发,再决定下一步去哪里放大流量和业务。",
caseResult: "持续约 14 个月发布约 1.2 万条推文,1 个月获得 1 万粉丝,证明这类输出确实有人需要。",
bodyText: [
"案例摘要:先把有价值的判断公开输出,用数据验证生产型兴趣是否真有市场需求。",
"关键过程:持续公开发布推文,先换到数据,再决定产品和平台迁移。",
"结果:持续约 14 个月发布约 1.2 万条推文,1 个月获得 1 万粉丝。",
"证明点:先换到数据,才能知道兴趣到底是不是值得继续做的生产型兴趣。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明主观点" },
{ type: "承接", targetKey: "sol-path", note: "案例落在增长路径的前两步" },
],
});
}
if (/686种组合|686 种组合/.test(textifyContext(context))) {
candidates.CAS.push({
key: "cas-686-combinations",
title: "686种组合证明行业问题无效",
caseSubject: "用 686 种组合说明为什么不能先问行业怎么做",
caseSummary: "把内容、流量、变现、产品四个维度拆开后,同一个行业内部就会出现大量组合,单问行业怎么做没有实际指导意义。",
caseProcess: "先拆内容载体,再拆流量放大方式,再拆变现路径,最后拆产品策略,把每个维度的组合乘起来,看见一个行业内部不是一条路径而是大量路径。",
caseResult: "仅按精简版拆解就能得到 686 种情况,已经足以说明研究行业标签本身不能帮人快速找到自己的从零到一路径。",
bodyText: [
"案例摘要:把内容、流量、变现、产品四个维度拆开后,同一个行业内部会出现大量组合。",
"关键过程:逐层拆内容、流量、变现、产品,并把它们按组合关系乘起来。",
"结果:仅精简版就能得到 686 种情况。",
"证明点:单问行业怎么做,无法直接导出一个适合你的具体生意路径。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明不能先问行业" },
],
});
}
if (isAntifragileIncomeContext(context) && /二十四万|24万|六万|6万|保险/.test(textifyContext(context))) {
candidates.CAS.push({
key: "cas-salary-insurance",
title: "24万收益换6万工资说明保险费逻辑",
caseSubject: "用雇佣关系解释工资为什么像保险费",
caseSummary: "员工创造的是波动收益,但要求雇主按月稳定付款,工资差额本质上就是把业务风险转手后的保险费。",
caseProcess: "先看岗位一年能创造多少价值,再看雇主必须在亏损月份也持续支付工资,最后比较总创造价值和总工资之间的差额。",
caseResult: "当一年创造 24 万、只拿 6 万工资时,中间的差额可以被理解为购买稳定性的隐性保险费。",
bodyText: [
"案例摘要:员工创造的是波动收益,但要求按月稳定发薪,差额本质上是保险费。",
"关键过程:比较岗位创造价值、工资刚性支付和风险承担方是谁。",
"结果:创造 24 万、拿 6 万工资时,差额可以被理解为稳定性保险费。",
"证明点:工资并不只是劳动报酬,也是一种把风险打包转移出去的交易安排。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明工资像保险费" },
],
});
}
if (isPassiveIncomeSystemContext(context)) {
candidates.CAS.push({
key: "cas-ai-affiliate",
title: "AI 产品分佣说明零投入层级",
caseSubject: "用 AI 产品分佣说明被动收入的最低层级",
caseSummary: "分享自己真的在用的 AI 产品,对方通过分佣链接购买,你获得佣金,这是一种接近零投入但也难以主动放量的被动收入模式。",
caseProcess: "先持续公开分享产品使用体验,再在有人主动来问时提供分佣链接,让系统自动完成折扣和返佣。",
caseResult: "连续 3 个月月均约 187 美金,证明零投入模式可以赚钱,但也暴露出放量能力弱的问题。",
bodyText: [
"案例摘要:分享真实使用体验,再通过分佣链接自动完成成交和返佣。",
"关键过程:先公开种草,再在用户主动询问时给链接,而不是自己手动成交。",
"结果:连续 3 个月月均约 187 美金。",
"证明点:零投入层级确实能赚钱,但很难主动扩张和放量。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明系统层级差异" },
],
});
candidates.CAS.push({
key: "cas-blogger-distribution",
title: "博主分销说明可放量但有天花板",
caseSubject: "用博主分销 PDF 说明系统可放量但有天花板",
caseSummary: "博主发内容,粉丝即时付费,系统自动分佣,这让分销者比员工还主动,但话题疲劳和博主总数会限制增长。",
caseProcess: "先打通销售链路和即时分佣,再持续拓展新博主;随着话题变窄和审美疲劳增加,必须越来越快地补充新渠道。",
caseResult: "系统具备主动放量能力,但最终仍会受博主总数、题材宽度和维护成本限制。",
bodyText: [
"案例摘要:通过即时分佣让博主主动帮你卖货,系统开始具备放量能力。",
"关键过程:打通博主发文、评论区成交、即时分佣这条链路,并持续补充新博主。",
"结果:可以放量,但会受到题材疲劳和渠道总数限制。",
"证明点:系统能放量,不代表天花板自动消失,维护仍是成本。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明系统赚钱的中间层级" },
],
});
candidates.CAS.push({
key: "cas-ip-long-tail",
title: "自增长长尾流量是更高层的系统",
caseSubject: "用自媒体 IP 说明更高层的被动收入系统",
caseSummary: "把研究 AI 产品这个兴趣,转成图文、视频、私域、课程和社群的组合,形成既能拿流量又能承接产品的长尾系统。",
caseProcess: "把兴趣内容拆成适合算法传播的流量内容和适合买家转化的产品内容,再让平台流量持续进入课程和社群。",
caseResult: "系统不仅能赚钱,还能把兴趣成本摊进业务里,让维护系统本身变成更愿意主动做的事。",
bodyText: [
"案例摘要:把兴趣、内容、流量和产品接成一个会自增长的长尾系统。",
"关键过程:图文去承接私域和品牌,视频去承接平台流量,流量再进入课程和社群。",
"结果:系统既能赚钱,也能让兴趣被纳入业务,而不是和业务分离。",
"证明点:更高层的被动收入,不只是自动化,更是兴趣和系统的长期结合。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-interest-in-system", note: "案例证明兴趣融入系统" },
],
});
}
if (isBossMindsetContext(context)) {
candidates.CAS.push({
key: "cas-lemonade-demand",
title: "卖柠檬水说明需求先于原料",
caseSubject: "用卖柠檬水说明为什么不能从我家有什么出发",
caseSummary: "真正能赚钱的表述不是我家有柠檬能不能卖,而是夏天很热、路上人多,所以我做冰柠檬水去满足需求。",
caseProcess: "先判断街上有没有人愿意买冷饮,再决定卖什么水果,而不是先看家里有什么原料。",
caseResult: "案例说明从原料和技能出发会把问题问反,需求才是更稳定的起点。",
bodyText: [
"案例摘要:不是因为家里有柠檬才去卖柠檬水,而是因为路上有人要买冷饮。",
"关键过程:先看需求,再决定用什么原料去满足需求。",
"结果:同样是卖饮料,需求导向的成功率远高于库存导向。",
"证明点:赚钱时先问用户要什么,比先问我手里有什么更重要。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明需求先于能力" },
],
});
candidates.CAS.push({
key: "cas-ai-course-wrong-order",
title: "先写课程大纲再问能不能卖是顺序错了",
caseSubject: "用 AI 课程案例说明为什么不能先做产品再找需求",
caseSummary: "如果一个人只会先列课程大纲,却完全不谈用户要解决什么问题,那么这个产品即使做完,也没有明确的成交对象和推出路径。",
caseProcess: "先写课程内容,再反过来问能不能卖;问题在于用户需求、产品定位和内容推出路径都还没有被确认。",
caseResult: "案例说明产品设计如果脱离需求验证,最后往往不是卖不掉,就是根本不知道怎么推出去。",
bodyText: [
"案例摘要:先把课程做完,再来问能不能卖,顺序已经错了。",
"关键过程:只谈课程结构,不谈用户需求、问题场景和推出路径。",
"结果:即使产品本身不差,也很难找到明确买家和推出方式。",
"证明点:先找需求,再做产品,比先做产品再问谁会买更有效。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明需求先于产品" },
],
});
candidates.CAS.push({
key: "cas-middleman-90",
title: "中介分走 90% 说明匹配需求更值钱",
caseSubject: "用中介分成说明匹配需求与产品才是核心价值",
caseSummary: "能做产品的人未必拿走大头,真正更值钱的是找到真实需求、匹配产品并完成转化的人。",
caseProcess: "先找到 A 的需求,再找到 B 的交付能力,最后把供需两端接起来;这时做连接和匹配的人会拿走大部分收益。",
caseResult: "案例说明在交易里,需求判断和产品匹配常常比单一执行能力更稀缺、更接近钱。",
bodyText: [
"案例摘要:会做东西的人不一定分得最多,接通需求和产品的人往往拿走更大价值。",
"关键过程:先找需求,再找产品提供者,最后完成撮合和转化。",
"结果:需求匹配与成交路径的价值,常常高于单独生产一个产品。",
"证明点:能力不是没价值,但它不是整个链路里最稀缺的那一段。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明需求匹配更接近钱" },
],
});
}
const experimentParagraph = findVideoParagraph(context, /1600万|一千六百万|十二万|12万|两万粉丝|2万粉丝/);
if (experimentParagraph) {
candidates.CAS.push({
key: "cas-douyin-experiment",
title: "先发 100 条再做抖音开头实验",
caseSubject: "通过高频发布和开头实验验证短视频反馈机制",
caseSummary: "先设定发满 100 条的反馈学习路径,再通过复用爆款开头的实验,验证短视频流量机制。",
caseProcess: "先不追求一条就火,而是先发满 100 条收集正负反馈;随后拿一条爆款开头做对照实验,观察为什么同样的开头会不会火。",
caseResult: "单条视频拿到约 1600 万播放,账号粉丝从约 2 万增长到约 12 万,直接验证了持续测试的回报。",
bodyText: [
"案例摘要:先通过高频发布建立反馈样本,再用开头实验验证流量机制。",
"关键过程:先发满 100 条,再做一次明确的变量实验,而不是靠上课替代测试。",
"结果:单条视频约 1600 万播放,粉丝从约 2 万增长到约 12 万。",
"证明点:真正有效的增长理解,来自可追溯的反馈实验,不来自抽象技巧清单。",
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明主观点" },
{ type: "证明", targetKey: "sol-path", note: "案例证明增长路径可行" },
],
});
}
const threePartSteps = buildThreePartSteps(context);
candidates.SOL.push({
key: "sol-three-part",
title: /生产型兴趣/.test(context.conceptText)
? "兴趣变现三要素检查法"
: isPassiveIncomeSystemContext(context)
? "系统化构建被动收入的三步方案"
: isBossMindsetContext(context)
? "从需求倒推而不是从能力出发"
: `${context.normalizedTitle} 的三步方案`,
targetProblem: mainQuestion,
solutionSummary: isIndustryStrategyContext(context)
? "先放弃赛道提问,再拆具体生意组合,最后用短周期测试判断这条路径你能不能做。"
: isAntifragileIncomeContext(context)
? "先识别稳定性的真实成本,再补足自己的反脆弱底盘,最后把收入目标改成和价值创造挂钩。"
: isPassiveIncomeSystemContext(context)
? "先承认被动收入的核心是系统,再判断系统层级,最后把兴趣融进可维护、可放量的结构。"
: isBossMindsetContext(context)
? "先从需求出发,再用对标验证内容和产品组合,最后倒推自己该补哪种能力。"
: "先确认兴趣是否具有生产性,再补齐与兴趣匹配的能力,最后把两者放进一个具体业务。",
actionSteps: threePartSteps,
expectedResult: isIndustryStrategyContext(context)
? "把行业层面的空问题改写成可以直接测试、直接迭代的具体生意路径。"
: isAntifragileIncomeContext(context)
? "把对稳定收入的依赖,逐步改写成对储备、能力和波动适应性的依赖。"
: isPassiveIncomeSystemContext(context)
? "逐步把收入从一次性人力交换,升级成一个能维护、能放量、也能和兴趣长期兼容的系统。"
: isBossMindsetContext(context)
? "把我会什么怎么变现,改造成谁有需求、什么组合被验证、我该补哪一环的清晰路径。"
: "把「兴趣能不能赚钱」改写成可以逐项核对、可以继续迭代的 3 步检查框架。",
bodyText: [
isIndustryStrategyContext(context)
? "方案摘要:先放弃赛道提问,再拆具体生意,最后做短周期测试。"
: isAntifragileIncomeContext(context)
? "方案摘要:先看稳定成本,再练反脆弱能力,最后重建收入观。"
: isPassiveIncomeSystemContext(context)
? "方案摘要:先搭系统,再分层级,最后把兴趣融进去。"
: isBossMindsetContext(context)
? "方案摘要:先看需求,再找对标,最后补能力。"
: "方案摘要:先判断生产性,再判断能力,再判断业务承接。",
buildBulletBody(threePartSteps),
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "方案回应主问题" },
{ type: "承接", targetKey: "opi-main", note: "方案落地核心观点" },
],
});
if (isAntifragileIncomeContext(context)) {
candidates.SOL.push({
key: "sol-antifragile-path",
title: "把自己练成反脆弱的路径",
targetProblem: mainQuestion,
solutionSummary: "避免高杠杆,保留储备金,持续扩大跨学科能力,让自己在风险和机会来临时拥有更多选择。",
actionSteps: [
"先避免高杠杆负债,减少房贷车贷这类会抽走你风险承受能力的固定成本",
"准备 6 到 12 个月储备金,让自己在波动中不被立刻击穿",
"持续学习跨行业、跨学科能力,让机遇和风险来临时自己能够升级而不是被动承受",
],
expectedResult: "当社会环境波动时,你不是更脆弱,而是更容易抓住机会并放大自身价值。",
bodyText: [
"方案摘要:减负债、留储备、练能力,把自己放到会因波动而变强的位置。",
buildBulletBody([
"先避免高杠杆负债,减少房贷车贷这类会抽走你风险承受能力的固定成本",
"准备 6 到 12 个月储备金,让自己在波动中不被立刻击穿",
"持续学习跨行业、跨学科能力,让机遇和风险来临时自己能够升级而不是被动承受",
]),
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "方案回应主问题" },
{ type: "承接", targetKey: "opi-stability-is-expensive", note: "方案承接稳定性代价判断" },
],
});
}
const pathSteps = buildPathSteps(context);
if (pathSteps.length > 0) {
candidates.SOL.push({
key: "sol-path",
title: /关键路径/.test((findSummaryBlock(context, /关键路径|增长路径|执行路径/) || {}).heading || "")
? removeLeadingSerial(findSummaryBlock(context, /关键路径|增长路径|执行路径/).heading)
: "从数据到业务的增长路径",
targetProblem: mainQuestion,
solutionSummary: "先公开输出换到数据,再用数据换产品,用产品倒逼能力,最后把能力放大成流量和商业模式。",
actionSteps: pathSteps,
expectedResult: "形成一条从兴趣出发、经过数据验证、最终落到具体业务的增长闭环。",
bodyText: [
"方案摘要:把兴趣先拿去换数据,再把数据依次换成产品、能力、流量和商业模式。",
buildBulletBody(pathSteps),
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "方案回应主问题" },
{ type: "承接", targetKey: "opi-main", note: "方案承接核心观点" },
],
});
}
if (/赛道思维|具体生意|686种组合/.test(`${context.coreTheme} ${context.normalizedTitle}`)) {
candidates.SOL.push({
key: "sol-test-path",
title: "从零到一测试具体生意的路径",
targetProblem: mainQuestion,
solutionSummary: "先找一个真实赚钱的具体生意,再拆它的组合方式,最后用短周期测试验证自己能不能做。",
actionSteps: [
"随机找一个已经赚钱的具体生意,而不是先研究整条行业赛道",
"把这个生意拆成内容、流量、变现、产品几个维度,看清它的闭环",
"给自己半个月到一个月去测试,失败就记录自己不擅长什么,下一次直接规避",
],
expectedResult: "更快找到一个你自己能从零到一跑通的具体生意,而不是停留在行业层面的空讨论。",
bodyText: [
"方案摘要:先找具体生意,再拆组合,再做短周期测试。",
buildBulletBody([
"随机找一个已经赚钱的具体生意,而不是先研究整条行业赛道",
"把这个生意拆成内容、流量、变现、产品几个维度,看清它的闭环",
"给自己半个月到一个月去测试,失败就记录自己不擅长什么,下一次直接规避",
]),
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "方案回应主问题" },
{ type: "承接", targetKey: "opi-main", note: "方案承接核心观点" },
{ type: "承接", targetKey: "opi-can-do-vs-you-can-do", note: "方案承接能力匹配判断" },
],
});
}
if (isPassiveIncomeSystemContext(context)) {
candidates.SOL.push({
key: "sol-passive-income-layers",
title: "构建被动收入系统的四层路径",
targetProblem: mainQuestion,
solutionSummary: "先用最低成本跑出自动成交链路,再升级到可放量结构,继续判断天花板,最后追求自增长长尾流量。",
actionSteps: [
"先用最低成本跑出一条能自动成交的最小链路,例如分佣、自动交付或即时分润",
"再把系统升级到可以主动放量的结构,例如渠道分销、广告放量或产品线扩展",
"继续判断天花板来自哪里,是题材、渠道、流量还是产品创新空间",
"最后把兴趣和稀缺内容能力接进系统,形成能自增长的长尾流量和更稳定的产品承接",
],
expectedResult: "你会清楚自己当前在哪一层,以及下一层应该升级系统的哪个部件。",
bodyText: [
"方案摘要:从零投入最小链路起步,升级到可放量结构,再追求自增长长尾流量。",
buildBulletBody([
"先用最低成本跑出一条能自动成交的最小链路,例如分佣、自动交付或即时分润",
"再把系统升级到可以主动放量的结构,例如渠道分销、广告放量或产品线扩展",
"继续判断天花板来自哪里,是题材、渠道、流量还是产品创新空间",
"最后把兴趣和稀缺内容能力接进系统,形成能自增长的长尾流量和更稳定的产品承接",
]),
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "方案回应主问题" },
{ type: "承接", targetKey: "opi-main", note: "方案承接系统赚钱判断" },
{ type: "承接", targetKey: "opi-interest-in-system", note: "方案承接兴趣融入系统判断" },
],
});
}
if (isBossMindsetContext(context)) {
candidates.SOL.push({
key: "sol-right-to-left",
title: "从右到左的赚钱判断路径",
targetProblem: mainQuestion,
solutionSummary: "先看需求,再看对标已验证的内容和产品组合,最后倒推自己要补哪种能力、找哪种合作。",
actionSteps: [
"先写清楚用户到底要解决什么问题,而不是先罗列自己有什么技能",
"去找已经赚钱的对标样本,确认什么内容和什么产品组合确实能满足这个需求",
"把整条链路拆成需求、内容、产品、交付几个环节,判断哪些自己做、哪些去学、哪些去雇人",
"最后再决定当前最值得投入的能力,而不是让已有技能反过来决定业务方向",
],
expectedResult: "把我会什么怎么变现,改造成谁有需求、什么组合被验证、我该补哪一环的清晰路径。",
bodyText: [
"方案摘要:先需求,后对标,再拆链路,最后补能力。",
buildBulletBody([
"先写清楚用户到底要解决什么问题,而不是先罗列自己有什么技能",
"去找已经赚钱的对标样本,确认什么内容和什么产品组合确实能满足这个需求",
"把整条链路拆成需求、内容、产品、交付几个环节,判断哪些自己做、哪些去学、哪些去雇人",
"最后再决定当前最值得投入的能力,而不是让已有技能反过来决定业务方向",
]),
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "回应", targetKey: "qst-main", note: "方案回应主问题" },
{ type: "承接", targetKey: "opi-main", note: "方案承接需求导向判断" },
{ type: "承接", targetKey: "opi-benchmark-proof", note: "方案承接对标验证判断" },
],
});
}
if (candidates.CAS.length === 0) {
candidates.CAS.push({
key: "cas-fallback",
title: `${context.normalizedTitle} 的关键案例`,
caseSubject: context.normalizedTitle,
caseSummary: truncateText(context.caseSummary || context.opinionText, 90),
caseProcess: truncateText(context.caseProcess, 120),
caseResult: truncateText(context.caseResult, 120),
bodyText: [
`案例摘要:${truncateText(context.caseSummary || context.opinionText, 120)}`,
`关键过程:${truncateText(context.caseProcess, 120)}`,
`结果:${truncateText(context.caseResult, 120)}`,
].join("\n\n"),
usageScenarios: sharedUsage,
relationshipRefs: [
{ type: "证明", targetKey: "opi-main", note: "案例证明主观点" },
],
});
}
for (const type of Object.keys(candidates)) {
candidates[type] = dedupeBy(candidates[type], (item) => `${item.title}::${item.bodyText}`);
}
return candidates;
}
function textifyContext(context) {
return [
context.title,
context.normalizedTitle,
context.coreTheme,
...(context.videoSummary || []),
...(context.summaryParagraphs || []),
...(context.structuredBullets || []),
...(context.summaryBlocks || []).map((item) => `${item.heading} ${item.body} ${(item.bullets || []).join(" ")}`),
].join("\n");
}
function applyTemplate(prefix, data) {
const templatePath = path.join(templateRoot, typeConfig[prefix].template);
let content = fs.readFileSync(templatePath, "utf8");
const formattedDate = `${data.date.slice(0, 4)}-${data.date.slice(4, 6)}-${data.date.slice(6, 8)}`;
content = content
.replace(`${prefix}-YYYYMMDD-001`, data.id)
.replace(/^title:\s*标题$/m, `title: ${data.title}`)
.replace(/^created_at:\s*YYYY-MM-DD$/m, `created_at: ${formattedDate}`)
.replace(/^updated_at:\s*YYYY-MM-DD$/m, `updated_at: ${formattedDate}`)
.replace(/^source_documents:\n(?: - .+\n)+/m, `source_documents:\n - ${data.sourceId}\n`)
.replace(/^source_authors:\n(?: - .+\n)+/m, `source_authors:\n - ${data.author}\n`)
.replace(/^themes:\n(?: - .+\n)+/m, `themes:\n - ${data.theme}\n`)
.replace(
/^keywords:\n(?: - .+\n)+/m,
`keywords:\n${data.keywords.map((item) => ` - ${item}`).join("\n")}\n`
);
if (prefix === "QST") {
content = content
.replace(/^question_text:\s*问题原句$/m, `question_text: ${data.questionText}`)
.replace(/^question_type:\s*认知问题$/m, `question_type: ${data.questionType}`)
.replace(
/^applicable_topics:\n(?: - .+\n)+/m,
`applicable_topics:\n${data.applicableTopics.map((item) => ` - ${item}`).join("\n")}\n`
);
}
if (prefix === "CON") {
content = content
.replace(/^concept_definition:\s*概念定义$/m, `concept_definition: ${data.conceptDefinition}`)
.replace(/^concept_function:\s*解释什么$/m, `concept_function: ${data.conceptFunction}`);
}
if (prefix === "OPI") {
content = content
.replace(/^core_claim:\s*核心判断$/m, `core_claim: ${data.coreClaim}`)
.replace(/^claim_scope:\s*适用范围$/m, `claim_scope: ${data.claimScope}`)
.replace(/^why_it_matters:\s*为什么重要$/m, `why_it_matters: ${data.whyItMatters}`);
}
if (prefix === "CAS") {
content = content
.replace(/^case_subject:\s*案例主体$/m, `case_subject: ${data.caseSubject}`)
.replace(/^case_summary:\s*案例摘要$/m, `case_summary: ${data.caseSummary}`)
.replace(/^case_process:\s*关键过程$/m, `case_process: ${data.caseProcess}`)
.replace(/^case_result:\s*结果$/m, `case_result: ${data.caseResult}`);
}
if (prefix === "SOL") {
content = content
.replace(/^target_problem:\s*解决什么问题$/m, `target_problem: ${data.targetProblem}`)
.replace(/^solution_summary:\s*方案摘要$/m, `solution_summary: ${data.solutionSummary}`)
.replace(
/^action_steps:\n(?: - .+\n)+/m,
`action_steps:\n${data.actionSteps.map((item) => ` - ${item}`).join("\n")}\n`
)
.replace(/^expected_result:\s*预期结果$/m, `expected_result: ${data.expectedResult}`);
}
if (data.relationships && data.relationships.length > 0) {
content = content.replace(
/^relationships:\s*\[\]$/m,
`relationships:\n${data.relationships
.map((item) => ` - type: ${item.type}\n target: ${item.target}\n note: ${item.note}`)
.join("\n")}`
);
}
content = content.replace(/^## 核心内容$/m, `## 核心内容\n\n${data.bodyText}\n`);
content = content.replace(
/^## 来源依据$/m,
`## 来源依据\n\n- 来源文件:${data.sourceRel}\n- 来源类型:${data.sourceType}\n`
);
content = content.replace(
/^## 使用场景$/m,
`## 使用场景\n\n${data.usageScenarios.map((item) => `- ${item}`).join("\n")}\n`
);
return content;
}
function writeUnit(prefix, data) {
const dir = path.join(unitRoot, typeConfig[prefix].dir);
ensureDir(dir);
const existingFiles = findExistingUnitFiles(prefix, data.title, data.sourceId, data.sourceRel);
const normalizedTargetPath = path.join(dir, `${data.id}_${slugFromTitle(data.title)}.md`);
let targetPath = normalizedTargetPath;
if (existingFiles[0]) {
targetPath = existingFiles[0];
if (targetPath !== normalizedTargetPath) {
if (fs.existsSync(normalizedTargetPath) && normalizedTargetPath !== targetPath) {
moveFileToTrash(normalizedTargetPath, "重复单元清理");
}
fs.renameSync(targetPath, normalizedTargetPath);
targetPath = normalizedTargetPath;
}
}
for (const staleFile of existingFiles.slice(1)) moveFileToTrash(staleFile, "重复单元清理");
fs.writeFileSync(targetPath, applyTemplate(prefix, data));
return targetPath;
}
function readFrontmatterValue(content, field) {
const match = content.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
return match ? match[1].trim() : "";
}
function readFrontmatterList(content, field) {
const match = content.match(new RegExp(`^${field}:\\n((?:\\s+-\\s+.+\\n?)*)`, "m"));
if (!match) return [];
return match[1]
.split("\n")
.map((line) => line.trim().replace(/^- /, "").trim())
.filter(Boolean);
}
function findExistingUnitFiles(prefix, title, sourceId, sourceRel) {
const dir = path.join(unitRoot, typeConfig[prefix].dir);
if (!fs.existsSync(dir)) return [];
const matches = [];
for (const name of fs.readdirSync(dir).sort((a, b) => a.localeCompare(b, "zh-Hans-CN"))) {
if (!name.endsWith(".md")) continue;
const filePath = path.join(dir, name);
const content = fs.readFileSync(filePath, "utf8");
const existingTitle = readFrontmatterValue(content, "title");
if (existingTitle !== title) continue;
const sourceDocuments = readFrontmatterList(content, "source_documents");
const sourceLines = content.match(/^-\s*来源文件:(.+)$/m);
const existingSourceRel = sourceLines ? sourceLines[1].trim() : "";
if (sourceDocuments.includes(sourceId) || existingSourceRel === sourceRel) matches.push(filePath);
}
return matches;
}
function resolveUnitIdentity(prefix, title, sourceId, sourceRel, dateText) {
const existingFiles = findExistingUnitFiles(prefix, title, sourceId, sourceRel);
if (existingFiles.length > 0) {
const firstExistingId = readFrontmatterValue(fs.readFileSync(existingFiles[0], "utf8"), "id");
return {
id: firstExistingId || extractIdFromFilename(existingFiles[0]) || nextId(prefix, dateText),
existingFiles,
};
}
return {
id: nextId(prefix, dateText),
existingFiles: [],
};
}
function loadExistingThemeMap(theme) {
const filePath = path.join(themeRoot, `${slugFromTitle(theme)}.md`);
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, "utf8");
}
function extractLinkedItems(content, heading) {
const match = content.match(new RegExp(`## ${heading}\\n\\n([\\s\\S]*?)(?=\\n## |$)`));
if (!match) return [];
return [...match[1].matchAll(/\[\[([^\]]+)\]\]/g)].map((item) => `[[${item[1]}]]`);
}
function findUnitFileByBasename(basename) {
if (!findUnitFileByBasename.cache) findUnitFileByBasename.cache = new Map();
if (findUnitFileByBasename.cache.has(basename)) return findUnitFileByBasename.cache.get(basename);
for (const config of Object.values(typeConfig)) {
const candidate = path.join(unitRoot, config.dir, `${basename}.md`);
if (fs.existsSync(candidate)) {
findUnitFileByBasename.cache.set(basename, candidate);
return candidate;
}
}
findUnitFileByBasename.cache.set(basename, "");
return "";
}
function linkedItemExists(linkedItem) {
const basename = linkedItem.replace(/^\[\[|\]\]$/g, "");
return Boolean(findUnitFileByBasename(basename));
}
function linkedItemMatchesSource(linkedItem, sourceId, sourceRel) {
const basename = linkedItem.replace(/^\[\[|\]\]$/g, "");
const filePath = findUnitFileByBasename(basename);
if (!filePath || !fs.existsSync(filePath)) return false;
const content = fs.readFileSync(filePath, "utf8");
const sourceDocuments = readFrontmatterList(content, "source_documents");
const sourceLineMatch = content.match(/^-\s*来源文件:(.+)$/m);
const existingSourceRel = sourceLineMatch ? sourceLineMatch[1].trim() : "";
return sourceDocuments.includes(sourceId) || existingSourceRel === sourceRel;
}
function ensureThemeMap(theme, overview, unitIndex, sourceId, sourceRel) {
ensureDir(themeRoot);
const filePath = path.join(themeRoot, `${slugFromTitle(theme)}.md`);
const existing = loadExistingThemeMap(theme);
const buckets = {
"核心问题单元": [],
"核心概念单元": [],
"核心观点单元": [],
"核心案例单元": [],
"核心方案单元": [],
};
if (existing) {
for (const heading of Object.keys(buckets)) {
buckets[heading] = extractLinkedItems(existing, heading).filter(
(item) => linkedItemExists(item) && !linkedItemMatchesSource(item, sourceId, sourceRel)
);
}
}
const appendUnique = (heading, link) => {
if (!link) return;
const wrapped = `[[${link}]]`;
if (!buckets[heading].includes(wrapped)) buckets[heading].push(wrapped);
};
appendUnique("核心问题单元", unitIndex.primary.QST);
for (const link of unitIndex.all.QST) appendUnique("核心问题单元", link);
appendUnique("核心概念单元", unitIndex.primary.CON);
for (const link of unitIndex.all.CON) appendUnique("核心概念单元", link);
appendUnique("核心观点单元", unitIndex.primary.OPI);
for (const link of unitIndex.all.OPI) appendUnique("核心观点单元", link);
appendUnique("核心案例单元", unitIndex.primary.CAS);
for (const link of unitIndex.all.CAS) appendUnique("核心案例单元", link);
appendUnique("核心方案单元", unitIndex.primary.SOL);
for (const link of unitIndex.all.SOL) appendUnique("核心方案单元", link);
const lines = [
`# 主题地图:${theme}`,
"",
"## 主题定义",
"",
overview.themeDefinition,
"",
"## 核心问题单元",
"",
...(buckets["核心问题单元"].length > 0 ? buckets["核心问题单元"].map((item) => `- ${item}`) : ["- 待补"]),
"",
"## 核心概念单元",
"",
...(buckets["核心概念单元"].length > 0 ? buckets["核心概念单元"].map((item) => `- ${item}`) : ["- 待补"]),
"",
"## 核心观点单元",
"",
...(buckets["核心观点单元"].length > 0 ? buckets["核心观点单元"].map((item) => `- ${item}`) : ["- 待补"]),
"",
"## 核心案例单元",
"",
...(buckets["核心案例单元"].length > 0 ? buckets["核心案例单元"].map((item) => `- ${item}`) : ["- 待补"]),
"",
"## 核心方案单元",
"",
...(buckets["核心方案单元"].length > 0 ? buckets["核心方案单元"].map((item) => `- ${item}`) : ["- 待补"]),
"",
"## 常见装配路径",
"",
`1. 问题:${overview.pathQuestion}`,
`2. 概念:${overview.pathConcept}`,
`3. 观点:${overview.pathOpinion}`,
`4. 案例:${overview.pathCase}`,
`5. 方案:${overview.pathSolution}`,
"",
"## 同主题可继续重组的补充单元",
"",
`- 补充问题:${overview.extraQuestions.length > 0 ? overview.extraQuestions.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
`- 补充观点:${overview.extraOpinions.length > 0 ? overview.extraOpinions.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
`- 补充案例:${overview.extraCases.length > 0 ? overview.extraCases.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
`- 补充方案:${overview.extraSolutions.length > 0 ? overview.extraSolutions.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
"",
"## 相关主题",
"",
...(overview.relatedThemes.length > 0 ? overview.relatedThemes.map((item) => `- ${item}`) : ["- 待补"]),
];
fs.writeFileSync(filePath, lines.join("\n") + "\n");
return filePath;
}
function ensureAssembly(title, overview, unitIndex) {
ensureDir(assemblyRoot);
const datePrefix = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
const filePath = path.join(assemblyRoot, `${datePrefix}_${slugFromTitle(title)}_装配稿.md`);
const lines = [
`# 选题装配:${title}`,
"",
"## 目标受众",
"",
overview.audience,
"",
"## 装配理由",
"",
overview.assemblyReason,
"",
"## 核心调用单元",
"",
"### 问题",
"",
unitIndex.primary.QST ? `- [[${unitIndex.primary.QST}]]` : "- 待补",
"",
"### 概念",
"",
unitIndex.primary.CON ? `- [[${unitIndex.primary.CON}]]` : "- 待补",
"",
"### 观点",
"",
unitIndex.primary.OPI ? `- [[${unitIndex.primary.OPI}]]` : "- 待补",
"",
"### 案例",
"",
unitIndex.primary.CAS ? `- [[${unitIndex.primary.CAS}]]` : "- 待补",
"",
"### 方案",
"",
unitIndex.primary.SOL ? `- [[${unitIndex.primary.SOL}]]` : "- 待补",
"",
"## 可追加调用单元",
"",
`- 补充问题:${overview.extraQuestions.length > 0 ? overview.extraQuestions.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
`- 补充观点:${overview.extraOpinions.length > 0 ? overview.extraOpinions.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
`- 补充案例:${overview.extraCases.length > 0 ? overview.extraCases.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
`- 补充方案:${overview.extraSolutions.length > 0 ? overview.extraSolutions.map((item) => `[[${item}]]`).join("、") : "暂无"}`,
"",
"## 建议结构",
"",
`1. 痛点:${overview.structure[0]}`,
`2. 冲突:${overview.structure[1]}`,
`3. 展开:${overview.structure[2]}`,
`4. 案例:${overview.structure[3]}`,
`5. 方法:${overview.structure[4]}`,
`6. 收束:${overview.structure[5]}`,
"",
"## 表达骨架",
"",
`### 开头\n\n${overview.bones.opening}`,
"",
`### 中段 1\n\n${overview.bones.body1}`,
"",
`### 中段 2\n\n${overview.bones.body2}`,
"",
`### 中段 3\n\n${overview.bones.body3}`,
"",
`### 结尾\n\n${overview.bones.closing}`,
];
fs.writeFileSync(filePath, lines.join("\n") + "\n");
return filePath;
}
function buildUnitOverview(context, theme, candidateBuckets, unitIndex) {
const primaryQuestion = pickPrimaryCandidate(candidateBuckets.QST, "qst-main");
const primaryConcept = pickPrimaryCandidate(candidateBuckets.CON, "con-main");
const primaryOpinion = pickPrimaryCandidate(candidateBuckets.OPI, "opi-main");
const primaryCase = pickPrimaryCandidate(candidateBuckets.CAS, "cas-public-output") || pickPrimaryCandidate(candidateBuckets.CAS);
const primarySolution = pickPrimaryCandidate(candidateBuckets.SOL, "sol-three-part");
const extraQuestions = unitIndex.all.QST.filter((item) => item !== unitIndex.primary.QST);
const extraOpinions = unitIndex.all.OPI.filter((item) => item !== unitIndex.primary.OPI);
const extraCases = unitIndex.all.CAS.filter((item) => item !== unitIndex.primary.CAS);
const extraSolutions = unitIndex.all.SOL.filter((item) => item !== unitIndex.primary.SOL);
const themeDefinition = cleanSentence(
primaryOpinion?.coreClaim ||
buildMainOpinion(context)
);
const caseSummary = primaryCase?.caseSummary || "用一段真实案例证明判断";
const solutionSummary = primarySolution?.solutionSummary || "先判断生产型兴趣,再补齐匹配能力,最后落到具体业务";
return {
themeDefinition,
pathQuestion: primaryQuestion?.questionText || buildMainQuestion(context),
pathConcept: primaryConcept?.conceptDefinition || context.conceptText,
pathOpinion: primaryOpinion?.coreClaim || buildMainOpinion(context),
pathCase: caseSummary,
pathSolution: solutionSummary,
relatedThemes: normalizeKeywords([context.coreTheme, ...context.keywords]).filter((item) => item !== theme).slice(0, 5),
audience: buildAudience(context),
assemblyReason: cleanSentence(
`这组装配先用「${primaryQuestion?.questionText || buildMainQuestion(context)}」把问题提纯,再用「${
primaryOpinion?.coreClaim || buildMainOpinion(context)
}」建立判断边界,随后用案例证明,最后用方案把判断落成可执行路径。`
),
structure: [
primaryQuestion?.questionText || buildMainQuestion(context),
primaryOpinion?.coreClaim || buildMainOpinion(context),
primaryConcept?.conceptDefinition || context.conceptText,
caseSummary,
solutionSummary,
buildClosingLine(context),
],
bones: {
opening: primaryQuestion?.questionText || buildMainQuestion(context),
body1: primaryConcept?.conceptDefinition || context.conceptText,
body2: primaryOpinion?.coreClaim || buildMainOpinion(context),
body3: solutionSummary,
closing: buildClosingLine(context),
},
extraQuestions,
extraOpinions,
extraCases,
extraSolutions,
reviewFocus: summarizeList([
primaryQuestion?.questionText,
primaryOpinion?.coreClaim,
primaryCase?.caseResult,
primarySolution?.expectedResult,
], 4),
};
}
function buildPayload(prefix, candidate, sourceMeta, context, dateText) {
const identity = resolveUnitIdentity(prefix, candidate.title, sourceMeta.sourceId, sourceMeta.sourceRel, dateText);
const payloadBase = {
id: identity.id,
sourceId: sourceMeta.sourceId,
author: sourceMeta.author,
theme: sourceMeta.theme,
keywords: buildKeywords(sourceMeta.title, context, [
candidate.title,
candidate.questionText,
candidate.coreClaim,
candidate.conceptDefinition,
candidate.caseSummary,
candidate.solutionSummary,
...(candidate.actionSteps || []),
]),
date: dateText,
sourceRel: sourceMeta.sourceRel,
sourceType: sourceMeta.sourceType,
relationships: [],
usageScenarios: candidate.usageScenarios || context.relatedScenes,
};
let payload;
if (prefix === "QST") {
payload = {
...payloadBase,
title: candidate.title,
questionText: candidate.questionText,
questionType: candidate.questionType,
applicableTopics: candidate.applicableTopics,
bodyText: candidate.bodyText,
};
} else if (prefix === "CON") {
payload = {
...payloadBase,
title: candidate.title,
conceptDefinition: candidate.conceptDefinition,
conceptFunction: candidate.conceptFunction,
bodyText: candidate.bodyText,
};
} else if (prefix === "OPI") {
payload = {
...payloadBase,
title: candidate.title,
coreClaim: candidate.coreClaim,
claimScope: candidate.claimScope,
whyItMatters: candidate.whyItMatters,
bodyText: candidate.bodyText,
};
} else if (prefix === "CAS") {
payload = {
...payloadBase,
title: candidate.title,
caseSubject: candidate.caseSubject,
caseSummary: candidate.caseSummary,
caseProcess: candidate.caseProcess,
caseResult: candidate.caseResult,
bodyText: candidate.bodyText,
};
} else {
payload = {
...payloadBase,
title: candidate.title,
targetProblem: candidate.targetProblem,
solutionSummary: candidate.solutionSummary,
actionSteps: candidate.actionSteps,
expectedResult: candidate.expectedResult,
bodyText: candidate.bodyText,
};
}
candidate.payload = payload;
candidate.existingFiles = identity.existingFiles;
return identity.id;
}
function extractUnitsFromContext(context, sourceMeta, dateText, createdUnits) {
const candidateBuckets = buildSemanticCandidates(context, sourceMeta.theme);
const unitIndex = {
primary: {},
all: { QST: [], CON: [], OPI: [], CAS: [], SOL: [] },
};
const keyToId = new Map();
const bucketOrder = ["QST", "CON", "OPI", "CAS", "SOL"];
const primaryKeys = {
QST: "qst-main",
CON: "con-main",
OPI: "opi-main",
CAS: "cas-public-output",
SOL: "sol-three-part",
};
for (const prefix of bucketOrder) {
for (const candidate of candidateBuckets[prefix]) {
const id = buildPayload(prefix, candidate, sourceMeta, context, dateText);
keyToId.set(candidate.key, id);
}
}
for (const prefix of bucketOrder) {
for (const candidate of candidateBuckets[prefix]) {
candidate.payload.relationships = (candidate.relationshipRefs || [])
.map((item) => {
const targetId = keyToId.get(item.targetKey);
if (!targetId) return null;
return { type: item.type, target: targetId, note: item.note };
})
.filter(Boolean);
const filePath = writeUnit(prefix, candidate.payload);
const basename = path.basename(filePath, ".md");
unitIndex.all[prefix].push(basename);
if (!unitIndex.primary[prefix]) unitIndex.primary[prefix] = basename;
if (primaryKeys[prefix] === candidate.key) unitIndex.primary[prefix] = basename;
createdUnits.push(path.relative(root, filePath).replaceAll(path.sep, "/"));
}
}
return { candidateBuckets, unitIndex };
}
const args = parseArgs(process.argv.slice(2));
if (args.help) usage(0);
let fileList = [];
if (args.files) fileList = args.files.split(",").map((item) => item.trim()).filter(Boolean);
if (args.plan) fileList = args.plan.split(",").map((item) => item.trim()).filter(Boolean);
if (fileList.length === 0) usage(1);
const registry = loadRegistry();
const processedRows = loadProcessedRows();
const processedSet = new Set(processedRows.slice(1).map((row) => row[0]));
const today = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
const dateText = (args.date || today.replaceAll("-", "")).trim();
if (!/^\d{8}$/.test(dateText)) {
console.error("date 必须是 YYYYMMDD");
process.exit(1);
}
const createdUnits = [];
const logLines = [`## ${today} 样本抽取`, ""];
const processedNow = [];
const skippedNow = [];
const skippedByClassifier = [];
for (const relPath of fileList) {
if (processedSet.has(relPath)) {
skippedNow.push(relPath);
continue;
}
const sourcePath = path.join(sourceRoot, relPath);
if (!fs.existsSync(sourcePath)) {
console.error(`样本不存在:${relPath}`);
process.exit(1);
}
const text = readTextSafe(sourcePath);
if (!text.trim()) {
console.error(`样本文稿无法读取文本:${relPath}`);
process.exit(1);
}
const title = titleFromPath(relPath);
const author = args.author || "待补";
const sourceId = registry.get(relPath) || "SRC-*";
const profile = classifySource(relPath, text);
if (profile.kind === "skip") {
processedRows.push([relPath, `已跳过:${profile.reason}`, inferSourceType(relPath), `分类器跳过于 ${today}`]);
processedSet.add(relPath);
skippedByClassifier.push(relPath);
logLines.push(`- 样本:${relPath}`);
logLines.push(` - 分类:跳过`);
logLines.push(` - 原因:${profile.reason}`);
continue;
}
const extractionItems =
profile.kind === "normalize-tweet-archive"
? normalizeTweetArchive(relPath, text)
: [
{
relPath,
title,
text,
},
];
let sourceCreatedCount = 0;
const sourceThemes = [];
const sourceAssemblies = [];
const generatedUnitNames = [];
for (const item of extractionItems) {
const itemTitle = item.title || title;
const itemText = item.text || text;
const context =
profile.kind === "extract-article"
? extractStructuredContext(itemText, itemTitle)
: extractSimpleContext(itemText, itemTitle, relPath, profile);
if (item.normalizedTitle) context.normalizedTitle = item.normalizedTitle;
if (item.primaryTheme) context.primaryTheme = item.primaryTheme;
if (item.sourceType) context.sourceType = item.sourceType;
const displayTitle = normalizeCandidateTitleText(context.normalizedTitle || itemTitle) || itemTitle;
const theme = inferTheme(item.relPath || relPath, args.theme, context);
const sourceMeta = {
title: displayTitle,
author,
sourceId,
sourceRel: relPath,
sourceType: context.sourceType || inferSourceType(relPath),
theme,
};
const { candidateBuckets, unitIndex } = extractUnitsFromContext(context, sourceMeta, dateText, createdUnits);
const overview = buildUnitOverview(context, theme, candidateBuckets, unitIndex);
const themeFile = ensureThemeMap(theme, overview, unitIndex, sourceId, relPath);
const assemblyFile = ensureAssembly(displayTitle, overview, unitIndex);
sourceCreatedCount += Object.values(unitIndex.all).flat().length;
sourceThemes.push(path.relative(root, themeFile).replaceAll(path.sep, "/"));
sourceAssemblies.push(path.relative(root, assemblyFile).replaceAll(path.sep, "/"));
generatedUnitNames.push(...Object.values(unitIndex.all).flat());
}
if (sourceCreatedCount === 0) {
processedRows.push([relPath, "待人工复核", inferSourceType(relPath), `分类为 ${profile.kind},但未生成内容单元`]);
processedSet.add(relPath);
skippedByClassifier.push(relPath);
logLines.push(`- 样本:${relPath}`);
logLines.push(` - 分类:${profile.kind}`);
logLines.push(" - 结果:未生成内容单元,已转人工复核");
continue;
}
processedRows.push([relPath, "已抽取样本", inferSourceType(relPath), `样本抽取于 ${today};模式:${profile.kind}`]);
processedSet.add(relPath);
processedNow.push(relPath);
logLines.push(`- 样本:${relPath}`);
logLines.push(` - 分类:${profile.kind}`);
logLines.push(` - 原因:${profile.reason}`);
logLines.push(` - 生成单元:${generatedUnitNames.join("、")}`);
logLines.push(` - 主题地图:${dedupeBy(sourceThemes, (item) => item).join("、")}`);
logLines.push(` - 装配稿:${dedupeBy(sourceAssemblies, (item) => item).join("、")}`);
}
saveProcessedRows(processedRows);
const ledgerStats = rebuildPendingLedger(processedSet);
appendLog(logLines);
upsertStatusOverview({
today,
scope: [
"当前目录已进入内容结构化系统样本模式",
processedNow.length > 0 ? `本轮处理文稿:${processedNow.join("、")}` : "本轮未新增处理文稿",
skippedNow.length > 0 ? `本轮跳过已处理文稿:${skippedNow.join("、")}` : "本轮无已处理跳过项",
skippedByClassifier.length > 0 ? `本轮被分类器跳过:${skippedByClassifier.join("、")}` : "本轮无分类器跳过项",
],
done: [
`本轮新增 ${createdUnits.length} 个内容单元`,
`已更新已处理清单,累计已处理 ${processedSet.size} 条`,
`已回收待处理清单,当前剩余 ${ledgerStats.pendingCount} 条`,
"已按分类、归一化、抽取三段式处理来源,并生成对应主题地图与装配稿",
],
todo: [
"运行关系索引、去重候选与 Obsidian 补链脚本",
"人工复核高价值单元的字段与边界",
"继续收紧去重规则,降低噪音候选",
],
next: [
"运行 `node 07-脚本与工具/generate-link-map.js`",
"运行 `node 07-脚本与工具/generate-duplicate-candidates.js`",
"运行 `node 07-脚本与工具/fill-obsidian-links.js`",
],
});
console.log(
JSON.stringify(
{
processedFiles: processedNow,
skippedFiles: skippedNow,
createdUnits,
count: createdUnits.length,
},
null,
2
)
);
tools/fill-obsidian-links.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const unitRoot = path.join(root, "02-内容单元库");
const targetRoots = [
path.join(root, "02-内容单元库"),
path.join(root, "05-主题地图"),
path.join(root, "06-选题装配"),
];
const codeFencePattern = /```[\s\S]*?```/g;
const associationSectionPattern = /\n## 关联单元\n([\s\S]*?)(\n## |\s*$)/;
function walkMarkdownFiles(dir) {
if (!fs.existsSync(dir)) return [];
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkMarkdownFiles(full));
else if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") files.push(full);
}
return files;
}
function extractFrontmatter(content) {
const match = content.match(/^(---\n[\s\S]*?\n---\n?)([\s\S]*)$/);
if (!match) return { frontmatter: "", body: content };
return { frontmatter: match[1], body: match[2] };
}
function getId(frontmatter) {
const match = frontmatter.match(/^id:\s*(.+)$/m);
return match ? match[1].trim() : "";
}
function getRelationshipTargets(frontmatter) {
return [...frontmatter.matchAll(/^\s*target:\s*(\S+)\s*$/gm)].map((match) => match[1]);
}
const idToLink = new Map();
for (const file of walkMarkdownFiles(unitRoot)) {
const content = fs.readFileSync(file, "utf8");
const { frontmatter } = extractFrontmatter(content);
const id = getId(frontmatter);
if (!id) continue;
idToLink.set(id, `[[${path.basename(file, ".md")}]]`);
}
let changedFiles = 0;
let changedLinks = 0;
let syncedAssociationFiles = 0;
let syncedAssociationLinks = 0;
function replaceLinksInBody(body) {
const codeFences = [];
const bodyWithoutCode = body.replace(codeFencePattern, (block) => {
const token = `__CODE_FENCE_${codeFences.length}__`;
codeFences.push(block);
return token;
});
let nextBody = bodyWithoutCode;
for (const [id, link] of idToLink.entries()) {
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const backtickPattern = new RegExp("`" + escapedId + "`", "g");
const barePattern = new RegExp(`(^|[^\\w\\[\\]])(${escapedId})(?=$|[^\\w_])`, "gm");
const backtickMatches = nextBody.match(backtickPattern);
if (backtickMatches) {
changedLinks += backtickMatches.length;
nextBody = nextBody.replace(backtickPattern, link);
}
const bareMatches = [...nextBody.matchAll(barePattern)].filter(([fullMatch]) => !fullMatch.includes(`[[${id}_`));
if (bareMatches.length > 0) {
changedLinks += bareMatches.length;
nextBody = nextBody.replace(barePattern, (_, prefix, matchedId) => `${prefix}${idToLink.get(matchedId)}`);
}
}
return nextBody.replace(/__CODE_FENCE_(\d+)__/g, (_, index) => codeFences[Number(index)]);
}
function syncAssociationSection(frontmatter, body) {
const targets = getRelationshipTargets(frontmatter)
.map((targetId) => idToLink.get(targetId))
.filter(Boolean);
if (targets.length === 0) return body;
const uniqueTargets = [...new Set(targets)];
const match = body.match(associationSectionPattern);
if (!match) {
syncedAssociationFiles += 1;
syncedAssociationLinks += uniqueTargets.length;
const trimmedBody = body.replace(/\s*$/, "");
return `${trimmedBody}\n\n## 关联单元\n\n${uniqueTargets.map((link) => `- ${link}`).join("\n")}\n`;
}
const sectionContent = match[1];
const existingLinks = [...sectionContent.matchAll(/\[\[([^\]]+)\]\]/g)].map((item) => `[[${item[1]}]]`);
const missingLinks = uniqueTargets.filter((link) => !existingLinks.includes(link));
if (missingLinks.length === 0) return body;
const nextSectionContent = `${sectionContent}${missingLinks.map((link) => `- ${link}\n`).join("")}`;
syncedAssociationFiles += 1;
syncedAssociationLinks += missingLinks.length;
return body.replace(associationSectionPattern, `\n## 关联单元\n${nextSectionContent}${match[2]}`);
}
for (const dir of targetRoots) {
for (const file of walkMarkdownFiles(dir)) {
const content = fs.readFileSync(file, "utf8");
const { frontmatter, body } = extractFrontmatter(content);
let nextBody = replaceLinksInBody(body);
if (file.startsWith(unitRoot)) {
nextBody = syncAssociationSection(frontmatter, nextBody);
}
if (nextBody === body) continue;
fs.writeFileSync(file, `${frontmatter}${nextBody}`);
changedFiles += 1;
}
}
console.log(JSON.stringify({
changedFiles,
changedLinks,
syncedAssociationFiles,
syncedAssociationLinks,
}, null, 2));
tools/generate-duplicate-candidates.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const unitRoot = path.join(root, "02-内容单元库");
const outputDir = path.join(root, "03-处理状态");
const csvOutput = path.join(outputDir, "去重候选索引.csv");
const summaryOutput = path.join(outputDir, "去重与冲突总览.md");
const auditOutput = path.join(outputDir, "去重与冲突审计.csv");
const manualCandidateInput = path.join(outputDir, "人工去重候选.csv");
const today = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
function walkFiles(dir) {
if (!fs.existsSync(dir)) return [];
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkFiles(full));
else if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") files.push(full);
}
return files;
}
function extractFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
return match ? match[1] : "";
}
function getScalar(frontmatter, field) {
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = frontmatter.match(new RegExp(`^${escaped}:\\s*(.+)$`, "m"));
return match ? match[1].trim() : "";
}
function getList(frontmatter, field) {
const lines = frontmatter.split("\n");
const start = lines.findIndex((line) => line.trim() === `${field}:`);
if (start === -1) return [];
const items = [];
for (let i = start + 1; i < lines.length; i += 1) {
const line = lines[i];
if (!line.startsWith(" - ")) break;
items.push(line.replace(" - ", "").trim());
}
return items;
}
function getRelationships(frontmatter) {
const lines = frontmatter.split("\n");
const relationships = [];
let inRelationships = false;
let current = null;
for (const line of lines) {
if (!inRelationships) {
if (line.trim() === "relationships:") inRelationships = true;
continue;
}
if (!line.startsWith(" ")) break;
const trimmed = line.trim();
if (trimmed === "[]") break;
const typeMatch = trimmed.match(/^- type:\s*(.+)$/);
if (typeMatch) {
current = { type: typeMatch[1].trim(), target: "", note: "" };
relationships.push(current);
continue;
}
if (!current) continue;
const targetMatch = trimmed.match(/^target:\s*(.+)$/);
if (targetMatch) {
current.target = targetMatch[1].trim();
continue;
}
const noteMatch = trimmed.match(/^note:\s*(.+)$/);
if (noteMatch) current.note = noteMatch[1].trim();
}
return relationships;
}
function uniqueIntersection(a, b) {
const bSet = new Set(b);
return [...new Set(a.filter((item) => bSet.has(item)))];
}
function normalizeText(text) {
return text.replace(/[^\p{Script=Han}A-Za-z0-9]+/gu, "");
}
function uniqueChars(text) {
return [...new Set(normalizeText(text).split(""))].filter(Boolean);
}
function titleSimilarity(a, b) {
const aChars = uniqueChars(a);
const bChars = uniqueChars(b);
if (aChars.length === 0 || bChars.length === 0) return 0;
const shared = uniqueIntersection(aChars, bChars).length;
return shared / Math.max(aChars.length, bChars.length);
}
function escapeCsv(value) {
return `"${String(value ?? "").replaceAll("\"", "\"\"")}"`;
}
function parseCsvLine(line) {
const cells = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (ch === "\"") {
if (inQuotes && line[i + 1] === "\"") {
current += "\"";
i += 1;
} else {
inQuotes = !inQuotes;
}
} else if (ch === "," && !inQuotes) {
cells.push(current);
current = "";
} else {
current += ch;
}
}
cells.push(current);
return cells;
}
function pairKey(a, b) {
return [a, b].sort().join("::");
}
function loadExistingReviews() {
const reviews = new Map();
if (!fs.existsSync(csvOutput)) return reviews;
const lines = fs.readFileSync(csvOutput, "utf8").trim().split("\n");
if (lines.length <= 1) return reviews;
const header = parseCsvLine(lines[0]);
const indexByName = Object.fromEntries(header.map((name, index) => [name, index]));
for (const line of lines.slice(1)) {
if (!line.trim()) continue;
const cells = parseCsvLine(line);
const unitA = cells[indexByName.unit_a_id];
const unitB = cells[indexByName.unit_b_id];
const status = cells[indexByName.status] || "待人工判断";
const note = cells[indexByName.note] || "";
reviews.set(pairKey(unitA, unitB), { status, note });
}
return reviews;
}
function loadManualCandidates() {
const manual = new Map();
if (!fs.existsSync(manualCandidateInput)) return manual;
const lines = fs.readFileSync(manualCandidateInput, "utf8").trim().split("\n");
if (lines.length <= 1) return manual;
const header = parseCsvLine(lines[0]);
const indexByName = Object.fromEntries(header.map((name, index) => [name, index]));
for (const line of lines.slice(1)) {
if (!line.trim()) continue;
const cells = parseCsvLine(line);
const unitA = cells[indexByName.unit_a_id];
const unitB = cells[indexByName.unit_b_id];
if (!unitA || !unitB) continue;
manual.set(pairKey(unitA, unitB), {
reason: cells[indexByName.reason] || "人工补充候选",
status: cells[indexByName.status] || "待人工判断",
note: cells[indexByName.note] || "",
});
}
return manual;
}
const units = walkFiles(unitRoot).map((file) => {
const content = fs.readFileSync(file, "utf8");
const frontmatter = extractFrontmatter(content);
return {
id: getScalar(frontmatter, "id"),
type: getScalar(frontmatter, "type"),
title: getScalar(frontmatter, "title"),
relPath: path.relative(root, file).replaceAll(path.sep, "/"),
themes: getList(frontmatter, "themes"),
keywords: getList(frontmatter, "keywords"),
sourceDocuments: getList(frontmatter, "source_documents"),
relationships: getRelationships(frontmatter),
};
}).filter((unit) => unit.id && unit.type && unit.title);
const unitById = new Map(units.map((unit) => [unit.id, unit]));
const candidates = [];
const existingReviews = loadExistingReviews();
const manualCandidates = loadManualCandidates();
const seenPairs = new Set();
for (let i = 0; i < units.length; i += 1) {
for (let j = i + 1; j < units.length; j += 1) {
const a = units[i];
const b = units[j];
if (a.type !== b.type) continue;
const sharedThemes = uniqueIntersection(a.themes, b.themes);
const sharedKeywords = uniqueIntersection(a.keywords, b.keywords);
const sharedSources = uniqueIntersection(a.sourceDocuments, b.sourceDocuments);
const similarity = titleSimilarity(a.title, b.title);
const score = sharedThemes.length * 3 + sharedKeywords.length * 2 + sharedSources.length * 2 + Math.round(similarity * 10);
const isCandidate =
(sharedThemes.length >= 1 && sharedKeywords.length >= 1) ||
(sharedSources.length >= 1 && (sharedThemes.length >= 1 || sharedKeywords.length >= 1)) ||
(similarity >= 0.35 && (sharedThemes.length >= 1 || sharedKeywords.length >= 1 || sharedSources.length >= 1));
if (!isCandidate) continue;
let candidateType = "近似重复候选";
if (sharedSources.length > 0 && sharedThemes.length > 0 && (sharedKeywords.length >= 1 || similarity >= 0.45)) {
candidateType = "同义重复候选";
}
const review = existingReviews.get(pairKey(a.id, b.id)) || { status: "待人工判断", note: "" };
const key = pairKey(a.id, b.id);
seenPairs.add(key);
candidates.push({
unit_a_id: a.id,
unit_a_type: a.type,
unit_a_title: a.title,
unit_b_id: b.id,
unit_b_type: b.type,
unit_b_title: b.title,
shared_themes: sharedThemes.join(" | "),
shared_keywords: sharedKeywords.join(" | "),
shared_sources: sharedSources.join(" | "),
title_similarity: similarity.toFixed(2),
score,
candidate_type: candidateType,
status: review.status,
note: review.note,
candidate_reason: "自动识别",
unit_a_file: a.relPath,
unit_b_file: b.relPath,
});
}
}
for (const [key, manual] of manualCandidates.entries()) {
if (seenPairs.has(key)) continue;
const [unitAId, unitBId] = key.split("::");
const a = unitById.get(unitAId);
const b = unitById.get(unitBId);
if (!a || !b) continue;
const sharedThemes = uniqueIntersection(a.themes, b.themes);
const sharedKeywords = uniqueIntersection(a.keywords, b.keywords);
const sharedSources = uniqueIntersection(a.sourceDocuments, b.sourceDocuments);
const similarity = titleSimilarity(a.title, b.title);
candidates.push({
unit_a_id: a.id,
unit_a_type: a.type,
unit_a_title: a.title,
unit_b_id: b.id,
unit_b_type: b.type,
unit_b_title: b.title,
shared_themes: sharedThemes.join(" | "),
shared_keywords: sharedKeywords.join(" | "),
shared_sources: sharedSources.join(" | "),
title_similarity: similarity.toFixed(2),
score: -1,
candidate_type: "人工补充候选",
status: manual.status,
note: manual.note,
candidate_reason: manual.reason,
unit_a_file: a.relPath,
unit_b_file: b.relPath,
});
}
candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const byType = a.unit_a_type.localeCompare(b.unit_a_type, "zh-Hans-CN");
if (byType !== 0) return byType;
return a.unit_a_id.localeCompare(b.unit_a_id, "zh-Hans-CN");
});
const rows = [
[
"unit_a_id",
"unit_a_type",
"unit_a_title",
"unit_b_id",
"unit_b_type",
"unit_b_title",
"shared_themes",
"shared_keywords",
"shared_sources",
"title_similarity",
"score",
"candidate_type",
"candidate_reason",
"status",
"note",
"unit_a_file",
"unit_b_file",
],
...candidates.map((row) => [
row.unit_a_id,
row.unit_a_type,
row.unit_a_title,
row.unit_b_id,
row.unit_b_type,
row.unit_b_title,
row.shared_themes,
row.shared_keywords,
row.shared_sources,
row.title_similarity,
row.score,
row.candidate_type,
row.candidate_reason,
row.status,
row.note,
row.unit_a_file,
row.unit_b_file,
]),
];
fs.writeFileSync(csvOutput, rows.map((row) => row.map(escapeCsv).join(",")).join("\n") + "\n");
const typeCounts = candidates.reduce((acc, row) => {
acc[row.candidate_type] = (acc[row.candidate_type] || 0) + 1;
return acc;
}, {});
const statusCounts = candidates.reduce((acc, row) => {
acc[row.status] = (acc[row.status] || 0) + 1;
return acc;
}, {});
function getRelationshipBetween(unit, targetId, type = null) {
return unit.relationships.find((relationship) => {
if (relationship.target !== targetId) return false;
if (type && relationship.type !== type) return false;
return true;
});
}
const auditRows = [];
const auditCounts = {
conflict_backlinked_ok: 0,
conflict_backlinked_missing: 0,
layered_link_ok: 0,
layered_link_not_required: 0,
layered_link_missing: 0,
};
for (const candidate of candidates) {
const unitA = unitById.get(candidate.unit_a_id);
const unitB = unitById.get(candidate.unit_b_id);
if (!unitA || !unitB) continue;
if (candidate.status === "已判断:不合并,建立冲突关系") {
const aConflict = getRelationshipBetween(unitA, unitB.id, "冲突");
const bConflict = getRelationshipBetween(unitB, unitA.id, "冲突");
const ok = Boolean(aConflict?.note && bConflict?.note);
auditCounts[ok ? "conflict_backlinked_ok" : "conflict_backlinked_missing"] += 1;
auditRows.push({
candidate_pair: `${unitA.id} <-> ${unitB.id}`,
candidate_status: candidate.status,
audit_type: "冲突回写审计",
audit_result: ok ? "通过" : "缺失",
audit_note: ok
? "双方内容单元均已写入带 note 的冲突关系"
: "候选已判断为建立冲突关系,但至少一侧未回写冲突关系或缺少 note",
});
continue;
}
if (candidate.status === "已判断:不合并,分层保留") {
const aToB = getRelationshipBetween(unitA, unitB.id);
const bToA = getRelationshipBetween(unitB, unitA.id);
const hasExplicitLink = Boolean(aToB || bToA);
const isConceptToJudgmentOrSolution =
(unitA.type === "概念单元" && (unitB.type === "观点单元" || unitB.type === "方案单元")) ||
(unitB.type === "概念单元" && (unitA.type === "观点单元" || unitA.type === "方案单元"));
const isQuestionPair = unitA.type === "问题单元" && unitB.type === "问题单元";
const isViewToSolution =
(unitA.type === "观点单元" && unitB.type === "方案单元") ||
(unitA.type === "方案单元" && unitB.type === "观点单元");
let auditResult = "缺失";
let auditNote = "候选已判断为分层保留,但对应内容单元之间缺少显式关系";
if (hasExplicitLink) {
auditResult = "通过";
auditNote = "至少一侧内容单元已写入显式关系,能支撑分层保留";
auditCounts.layered_link_ok += 1;
} else if (isQuestionPair || isViewToSolution) {
auditResult = "无需";
auditNote = "当前分层保留组合不强制要求显式关系";
auditCounts.layered_link_not_required += 1;
} else if (isConceptToJudgmentOrSolution) {
auditResult = "缺失";
auditNote = "概念单元分层保留时,应至少通过 `解释` 关系显式支撑对应观点或方案";
auditCounts.layered_link_missing += 1;
} else {
auditResult = "无需";
auditNote = "当前分层保留组合不强制要求显式关系";
auditCounts.layered_link_not_required += 1;
}
auditRows.push({
candidate_pair: `${unitA.id} <-> ${unitB.id}`,
candidate_status: candidate.status,
audit_type: "分层保留关系审计",
audit_result: auditResult,
audit_note: auditNote,
});
}
}
const auditCsvRows = [
["candidate_pair", "candidate_status", "audit_type", "audit_result", "audit_note"],
...auditRows.map((row) => [row.candidate_pair, row.candidate_status, row.audit_type, row.audit_result, row.audit_note]),
];
fs.writeFileSync(auditOutput, auditCsvRows.map((row) => row.map(escapeCsv).join(",")).join("\n") + "\n");
const summaryLines = [
"# 去重与冲突总览",
"",
`最后更新:${today}`,
"",
"## 当前统计",
"",
`- 内容单元总数:${units.length}`,
`- 去重候选总数:${candidates.length}`,
"",
"## 候选类型分布",
"",
];
for (const type of Object.keys(typeCounts).sort((a, b) => a.localeCompare(b, "zh-Hans-CN"))) {
summaryLines.push(`- ${type}:${typeCounts[type]}`);
}
if (Object.keys(typeCounts).length === 0) summaryLines.push("- 暂无候选");
summaryLines.push("", "## 处理状态分布", "");
for (const status of Object.keys(statusCounts).sort((a, b) => a.localeCompare(b, "zh-Hans-CN"))) {
summaryLines.push(`- ${status}:${statusCounts[status]}`);
}
if (Object.keys(statusCounts).length === 0) summaryLines.push("- 暂无状态");
summaryLines.push(
"",
"## 当前说明",
"",
"- 本文件先给出去重候选,不自动合并",
"- 自动识别候选与人工补充候选会同时进入索引",
"- 已判断为 `建立冲突关系` 的候选,会审计双方内容单元是否都已回写 `冲突` 关系",
"- 已判断为 `分层保留` 的候选,会审计对应内容单元之间是否存在显式关系支撑",
"",
"## 关系审计",
"",
`- 冲突回写已通过:${auditCounts.conflict_backlinked_ok}`,
`- 冲突回写待补:${auditCounts.conflict_backlinked_missing}`,
`- 分层保留关系已通过:${auditCounts.layered_link_ok}`,
`- 分层保留关系无需直连:${auditCounts.layered_link_not_required}`,
`- 分层保留关系待补:${auditCounts.layered_link_missing}`,
"",
"## 权威文件",
"",
`- 候选明细:\`${path.relative(root, csvOutput).replaceAll(path.sep, "/")}\``,
`- 审计明细:\`${path.relative(root, auditOutput).replaceAll(path.sep, "/")}\``,
`- 人工补充候选:\`${path.relative(root, manualCandidateInput).replaceAll(path.sep, "/")}\``,
"- 去重规则:`00-规则与索引/内容单元去重与版本规则.md`",
"- 关系规则:`00-规则与索引/内容单元关系规则.md`",
);
fs.writeFileSync(summaryOutput, `${summaryLines.join("\n")}\n`);
console.log(JSON.stringify({
csvOutput,
summaryOutput,
auditOutput,
unitCount: units.length,
candidateCount: candidates.length,
}, null, 2));
tools/generate-link-map.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const unitRoot = path.join(root, "02-内容单元库");
const themeRoot = path.join(root, "05-主题地图");
const assemblyRoot = path.join(root, "06-选题装配");
const stateRoot = path.join(root, "03-处理状态");
const relationCsv = path.join(stateRoot, "关系索引.csv");
const relationSummary = path.join(stateRoot, "关系总览.md");
const today = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
function walkFiles(dir) {
if (!fs.existsSync(dir)) return [];
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkFiles(full));
else if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") files.push(full);
}
return files;
}
function extractFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
return match ? match[1] : "";
}
function getScalar(frontmatter, field) {
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = frontmatter.match(new RegExp(`^${escaped}:\\s*(.+)$`, "m"));
return match ? match[1].trim() : "";
}
function getRelationships(frontmatter) {
if (/^relationships:\s*\[\s*\]\s*$/m.test(frontmatter)) return [];
const lines = frontmatter.split("\n");
const start = lines.findIndex((line) => line.trim() === "relationships:");
if (start === -1) return [];
const block = [];
for (let i = start + 1; i < lines.length; i += 1) {
const line = lines[i];
if (!line.startsWith(" ")) break;
block.push(line);
}
const relationships = [];
let current = null;
for (const line of block) {
const trimmed = line.trim();
if (trimmed.startsWith("- type:")) {
if (current) relationships.push(current);
current = { type: trimmed.slice("- type:".length).trim(), target: "", note: "" };
continue;
}
if (!current) continue;
if (trimmed.startsWith("target:")) current.target = trimmed.slice("target:".length).trim();
if (trimmed.startsWith("note:")) current.note = trimmed.slice("note:".length).trim();
}
if (current) relationships.push(current);
return relationships;
}
function escapeCsv(value) {
return `"${String(value ?? "").replaceAll("\"", "\"\"")}"`;
}
const files = walkFiles(unitRoot);
const units = files.map((file) => {
const content = fs.readFileSync(file, "utf8");
const frontmatter = extractFrontmatter(content);
return {
file,
relPath: path.relative(root, file).replaceAll(path.sep, "/"),
id: getScalar(frontmatter, "id"),
type: getScalar(frontmatter, "type"),
title: getScalar(frontmatter, "title"),
relationships: getRelationships(frontmatter),
};
});
const unitById = new Map(units.map((unit) => [unit.id, unit]));
const relationRows = [];
for (const unit of units) {
for (const relation of unit.relationships) {
const targetUnit = unitById.get(relation.target);
relationRows.push({
source_id: unit.id,
source_type: unit.type,
source_title: unit.title,
relation_type: relation.type,
target_id: relation.target,
target_type: targetUnit ? targetUnit.type : "",
target_title: targetUnit ? targetUnit.title : "",
note: relation.note || "",
source_file: unit.relPath,
target_file: targetUnit ? targetUnit.relPath : "",
status: targetUnit ? "有效" : "目标缺失",
});
}
}
relationRows.sort((a, b) => {
const bySource = a.source_id.localeCompare(b.source_id, "zh-Hans-CN");
if (bySource !== 0) return bySource;
const byType = a.relation_type.localeCompare(b.relation_type, "zh-Hans-CN");
if (byType !== 0) return byType;
return a.target_id.localeCompare(b.target_id, "zh-Hans-CN");
});
const rows = [[
"source_id","source_type","source_title","relation_type","target_id","target_type","target_title","note","source_file","target_file","status"
], ...relationRows.map((row) => [
row.source_id,row.source_type,row.source_title,row.relation_type,row.target_id,row.target_type,row.target_title,row.note,row.source_file,row.target_file,row.status
])];
fs.writeFileSync(relationCsv, rows.map((row) => row.map(escapeCsv).join(",")).join("\n") + "\n");
const relationTypeCounts = relationRows.reduce((acc, row) => {
acc[row.relation_type] = (acc[row.relation_type] || 0) + 1;
return acc;
}, {});
const unitsWithRelationships = units.filter((unit) => unit.relationships.length > 0).length;
const missingTargets = relationRows.filter((row) => row.status !== "有效");
const lines = [
"# 关系总览",
"",
`最后更新:${today}`,
"",
"## 当前统计",
"",
`- 内容单元总数:${units.length}`,
`- 含关系的内容单元数:${unitsWithRelationships}`,
`- 关系总数:${relationRows.length}`,
"",
"## 关系类型分布",
"",
];
for (const type of Object.keys(relationTypeCounts).sort((a, b) => a.localeCompare(b, "zh-Hans-CN"))) {
lines.push(`- ${type}:${relationTypeCounts[type]}`);
}
if (Object.keys(relationTypeCounts).length === 0) lines.push("- 暂无关系");
lines.push("", "## 校验结果", "");
if (missingTargets.length === 0) lines.push("- 所有关系统一指向有效内容单元");
else {
lines.push(`- 存在 ${missingTargets.length} 条目标缺失关系`);
for (const row of missingTargets) lines.push(`- ${row.source_id} -> ${row.target_id}(${row.relation_type})`);
}
lines.push("", "## 权威文件", "", "- 明细索引:`03-处理状态/关系索引.csv`", "- 关系规则:`00-规则与索引/内容单元关系规则.md`");
fs.writeFileSync(relationSummary, lines.join("\n") + "\n");
console.log(JSON.stringify({
relationCsv,
relationSummary,
totalUnits: units.length,
unitsWithRelationships,
relationCount: relationRows.length,
themeCount: walkFiles(themeRoot).filter((file) => path.basename(file).toLowerCase() !== "readme.md").length,
assemblyCount: walkFiles(assemblyRoot).filter((file) => path.basename(file).toLowerCase() !== "readme.md").length,
}, null, 2));
tools/generate-source-registry.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const sourceRoot = path.join(root, "01-原始素材区");
const stateRoot = path.join(root, "03-处理状态");
const output = path.join(stateRoot, "来源注册表_批量生成候选.csv");
const registryPath = path.join(stateRoot, "来源注册表.csv");
const rules = [
{ keywords: ["短视频", "文稿"], type: "短视频", code: "VIDEO" },
{ keywords: ["公众号"], type: "公众号文章", code: "WX" },
{ keywords: ["观点与概念"], type: "观点与概念", code: "CON" },
{ keywords: ["爆款文稿"], type: "爆款文稿", code: "BK" },
{ keywords: ["推文"], type: "推文素材", code: "POST" },
{ keywords: ["其他作者"], type: "外部研究素材", code: "EXT" },
{ keywords: ["dontbesilent"], type: "本人内容", code: "USER" },
];
function walk(dir) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.name.startsWith(".")) continue;
if (entry.isDirectory()) results.push(...walk(full));
else if (entry.isFile()) results.push(full);
}
return results;
}
function parseCsvLine(line) {
const cells = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (ch === "\"") {
if (inQuotes && line[i + 1] === "\"") {
current += "\"";
i += 1;
} else {
inQuotes = !inQuotes;
}
} else if (ch === "," && !inQuotes) {
cells.push(current);
current = "";
} else {
current += ch;
}
}
cells.push(current);
return cells;
}
function escapeCsv(value) {
return `"${String(value ?? "").replaceAll("\"", "\"\"")}"`;
}
function loadRegistry() {
const byPath = new Map();
const maxSeqByCode = new Map();
const usedIds = new Set();
if (!fs.existsSync(registryPath)) return { byPath, maxSeqByCode, usedIds };
const lines = fs.readFileSync(registryPath, "utf8").split("\n").filter(Boolean);
if (lines.length <= 1) return { byPath, maxSeqByCode, usedIds };
for (const line of lines.slice(1)) {
const [sourceId, relPath] = parseCsvLine(line);
if (!sourceId || !relPath) continue;
byPath.set(relPath, sourceId);
usedIds.add(sourceId);
const match = sourceId.match(/^SRC-([A-Z]+)-(\d{3})$/);
if (!match) continue;
const [, code, seq] = match;
maxSeqByCode.set(code, Math.max(maxSeqByCode.get(code) || 0, Number(seq)));
}
return { byPath, maxSeqByCode, usedIds };
}
function inferRule(relPath) {
const normalized = relPath.replaceAll("\\", "/");
for (const rule of rules) {
if (rule.keywords.every((item) => normalized.includes(item))) return rule;
}
return { type: "未分类素材", code: "MISC" };
}
function inferStableId(rule, relPath, existing) {
if (existing.byPath.has(relPath)) return existing.byPath.get(relPath);
const base = path.basename(relPath);
const videoMatch = base.match(/^(\d{3})\.[^.]+$/);
if (rule.code === "VIDEO" && videoMatch) {
const id = `SRC-VIDEO-${videoMatch[1]}`;
existing.usedIds.add(id);
existing.maxSeqByCode.set(rule.code, Math.max(existing.maxSeqByCode.get(rule.code) || 0, Number(videoMatch[1])));
return id;
}
const burstMatch = base.match(/^(\d{2})-/);
if (rule.code === "BK" && burstMatch) {
const seq = Number(burstMatch[1]);
const id = `SRC-BK-${String(seq).padStart(3, "0")}`;
existing.usedIds.add(id);
existing.maxSeqByCode.set(rule.code, Math.max(existing.maxSeqByCode.get(rule.code) || 0, seq));
return id;
}
let next = existing.maxSeqByCode.get(rule.code) || 0;
let id = "";
do {
next += 1;
id = `SRC-${rule.code}-${String(next).padStart(3, "0")}`;
} while (existing.usedIds.has(id));
existing.maxSeqByCode.set(rule.code, next);
existing.usedIds.add(id);
return id;
}
const existing = loadRegistry();
const rows = [["source_id", "path", "source_type", "author", "status", "notes"]];
const files = walk(sourceRoot)
.filter((file) => /\.(md|txt|html|csv|json|jsonl|docx|pdf)$/i.test(file))
.map((file) => path.relative(sourceRoot, file).replaceAll(path.sep, "/"))
.sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
for (const rel of files) {
const rule = inferRule(rel);
const id = inferStableId(rule, rel, existing);
rows.push([id, rel, rule.type, "待补", "候选", "脚本生成,待人工确认"]);
}
fs.writeFileSync(output, rows.map((row) => row.map(escapeCsv).join(",")).join("\n") + "\n");
console.log(JSON.stringify({
output,
count: rows.length - 1,
}, null, 2));
tools/generate-unit-draft.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const templateRoot = path.join(root, "04-模板");
const unitRoot = path.join(root, "02-内容单元库");
const typeMap = {
QST: { dir: "问题单元", template: "问题单元模板.md" },
CON: { dir: "概念单元", template: "概念单元模板.md" },
OPI: { dir: "观点单元", template: "观点单元模板.md" },
CAS: { dir: "案例单元", template: "案例单元模板.md" },
SOL: { dir: "方案单元", template: "方案单元模板.md" },
};
function fail(message) {
console.error(message);
process.exit(1);
}
const [, , prefix, date, seq, title, sourceId = "SRC-*", theme = "主题", keyword = "关键词", author = "待补"] = process.argv;
if (!prefix || !date || !seq || !title) {
fail("用法:node 07-脚本与工具/generate-unit-draft.js <QST|CON|OPI|CAS|SOL> <YYYYMMDD> <序号3位> <标题> [sourceId] [theme] [keyword] [author]");
}
if (!typeMap[prefix]) fail(`不支持的类型前缀:${prefix}`);
if (!/^\d{8}$/.test(date)) fail("日期必须是 YYYYMMDD");
if (!/^\d{3}$/.test(seq)) fail("序号必须是 3 位数字");
const formattedDate = `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`;
const id = `${prefix}-${date}-${seq}`;
const meta = typeMap[prefix];
const templatePath = path.join(templateRoot, meta.template);
const targetDir = path.join(unitRoot, meta.dir);
const fileName = `${id}_${title}.md`;
const targetPath = path.join(targetDir, fileName);
if (!fs.existsSync(templatePath)) fail(`模板不存在:${templatePath}`);
if (fs.existsSync(targetPath)) fail(`文件已存在:${targetPath}`);
let content = fs.readFileSync(templatePath, "utf8");
content = content
.replace(`${prefix}-YYYYMMDD-001`, id)
.replace(/^title:\s*标题$/m, `title: ${title}`)
.replace(/^ - SRC-\*$/m, ` - ${sourceId}`)
.replace(/^ - 待补$/m, ` - ${author}`)
.replace(/^ - 主题$/m, ` - ${theme}`)
.replace(/^ - 关键词$/m, ` - ${keyword}`)
.replace(/^created_at:\s*YYYY-MM-DD$/m, `created_at: ${formattedDate}`)
.replace(/^updated_at:\s*YYYY-MM-DD$/m, `updated_at: ${formattedDate}`);
fs.writeFileSync(targetPath, content);
console.log(targetPath);
tools/init-content-system.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const skillRoot = __dirname.startsWith(path.sep)
? path.resolve(__dirname, "..")
: path.resolve(process.cwd(), path.dirname(__filename), "..");
const targetArg = process.argv[2];
if (!targetArg) {
console.error("用法:node tools/init-content-system.js <目标工程目录>");
process.exit(1);
}
const targetRoot = path.isAbsolute(targetArg)
? path.resolve(targetArg)
: path.resolve(process.cwd(), targetArg);
const dirs = [
"00-规则与索引",
"01-原始素材区",
"01-原始素材区/完整副本",
"02-内容单元库/问题单元",
"02-内容单元库/概念单元",
"02-内容单元库/观点单元",
"02-内容单元库/案例单元",
"02-内容单元库/方案单元",
"03-处理状态",
"04-模板",
"05-主题地图",
"06-选题装配",
"07-脚本与工具",
];
for (const dir of dirs) {
fs.mkdirSync(path.join(targetRoot, dir), { recursive: true });
}
const files = {
"03-处理状态/来源注册表.csv": "\"source_id\",\"path\",\"source_type\",\"author\",\"status\",\"notes\"\n",
"03-处理状态/来源注册表_批量生成候选.csv": "\"source_id\",\"path\",\"source_type\",\"author\",\"status\",\"notes\"\n",
"03-处理状态/原始素材索引.csv": "\"path\",\"category\"\n",
"03-处理状态/待处理清单.csv": "\"path\",\"status\",\"source_type\",\"notes\"\n",
"03-处理状态/已处理清单.csv": "\"path\",\"status\",\"source_type\",\"notes\"\n",
"03-处理状态/人工去重候选.csv": "\"unit_a_id\",\"unit_b_id\",\"reason\",\"status\",\"note\"\n",
"03-处理状态/处理状态总览.md": "# 处理状态总览\n\n最后更新:待补\n\n## 当前范围\n\n- 待补\n\n## 当前已完成\n\n- 工程骨架已建立\n\n## 当前未完成\n\n- 待补\n\n## 下一步\n\n- 复制原始素材\n- 生成来源候选与原始索引\n- 运行首批样本抽取\n",
"03-处理状态/抽取日志.md": "# 抽取日志\n",
"03-处理状态/第一批样本计划.md": "# 第一批样本计划\n\n## 目标\n\n- 选择 3 到 5 篇代表性样本文稿\n- 首批至少产出 15 个内容单元\n\n## 样本清单\n\n- 待补\n",
};
for (const [rel, content] of Object.entries(files)) {
const filePath = path.join(targetRoot, rel);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, content);
}
}
const templateNames = [
"问题单元模板.md",
"概念单元模板.md",
"观点单元模板.md",
"案例单元模板.md",
"方案单元模板.md",
"主题地图模板.md",
"选题装配模板.md",
];
for (const name of templateNames) {
const src = path.join(skillRoot, "templates", name);
const dst = path.join(targetRoot, "04-模板", name);
fs.copyFileSync(src, dst);
}
const scaffoldRoot = path.join(skillRoot, "scaffold", "root");
for (const name of ["AGENTS.md", "CLAUDE.md", "README.md", "SOURCE_OF_TRUTH.md"]) {
const src = path.join(scaffoldRoot, name);
const dst = path.join(targetRoot, name);
fs.copyFileSync(src, dst);
}
const scaffoldRules = path.join(skillRoot, "scaffold", "rules");
for (const name of [
"内容单元字段规范.md",
"内容单元关系规则.md",
"内容单元去重与版本规则.md",
"处理流程.md",
"新增文稿进入系统流程.md",
"来源命名规范.md",
]) {
const src = path.join(scaffoldRules, name);
const dst = path.join(targetRoot, "00-规则与索引", name);
fs.copyFileSync(src, dst);
}
const toolNames = [
"rebuild-processing-ledger.js",
"generate-unit-draft.js",
"extract-sample-units.js",
"assemble-topic-from-units.js",
"generate-source-registry.js",
"generate-link-map.js",
"generate-duplicate-candidates.js",
"fill-obsidian-links.js",
"summarize-system.js",
];
for (const name of toolNames) {
const src = path.join(skillRoot, "tools", name);
const dst = path.join(targetRoot, "07-脚本与工具", name);
fs.copyFileSync(src, dst);
}
console.log(JSON.stringify({ targetRoot, created: true }, null, 2));
tools/rebuild-processing-ledger.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
const sourceRoot = path.join(root, "01-原始素材区");
const stateRoot = path.join(root, "03-处理状态");
const rawIndexPath = path.join(stateRoot, "原始素材索引.csv");
const pendingPath = path.join(stateRoot, "待处理清单.csv");
const processedPath = path.join(stateRoot, "已处理清单.csv");
const catalog = [
{ category: "短视频", sourceType: "短视频", dirs: ["短视频/文稿"] },
{ category: "公众号", sourceType: "公众号文章", dirs: ["公众号"] },
{ category: "观点与概念", sourceType: "观点与概念", dirs: ["观点与概念"] },
{ category: "爆款文稿", sourceType: "爆款文稿", dirs: ["爆款文稿"] },
{ category: "推文", sourceType: "推文素材", dirs: ["推文"] },
{ category: "其他作者", sourceType: "外部研究素材", dirs: ["其他作者"] },
{ category: "dontbesilent", sourceType: "本人内容", dirs: ["dontbesilent"] },
{ category: "完整副本", sourceType: "完整副本", dirs: ["完整副本"] },
];
function walkFiles(dir) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) results.push(...walkFiles(full));
else if (entry.isFile() && /\.(md|txt|html|csv|json|jsonl)$/i.test(entry.name)) results.push(full);
}
return results;
}
function csvEscape(value) {
return `"${String(value).replaceAll("\"", "\"\"")}"`;
}
function readProcessedPaths() {
const processed = new Set();
if (!fs.existsSync(processedPath)) return processed;
const lines = fs.readFileSync(processedPath, "utf8").split("\n").slice(1);
for (const line of lines) {
if (!line.trim()) continue;
const match = line.match(/^"((?:[^"]|"")*)"/);
if (!match) continue;
processed.add(match[1].replaceAll("\"\"", "\""));
}
return processed;
}
const processed = readProcessedPaths();
const rawRows = [["path", "category"]];
const pendingRows = [["path", "status", "source_type", "notes"]];
for (const rule of catalog) {
const files = rule.dirs
.flatMap((rel) => walkFiles(path.join(sourceRoot, rel)))
.map((file) => path.relative(sourceRoot, file).replaceAll(path.sep, "/"))
.sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
for (const rel of files) {
rawRows.push([rel, rule.category]);
if (!processed.has(rel)) pendingRows.push([rel, "待处理", rule.sourceType, ""]);
}
}
fs.writeFileSync(rawIndexPath, rawRows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n");
fs.writeFileSync(pendingPath, pendingRows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n");
console.log(JSON.stringify({
rawIndexPath,
pendingPath,
rawCount: rawRows.length - 1,
pendingCount: pendingRows.length - 1,
}, null, 2));
tools/summarize-system.js›
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(process.cwd());
function walkFiles(dir) {
if (!fs.existsSync(dir)) return [];
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkFiles(full));
else if (entry.isFile()) files.push(full);
}
return files;
}
function isContentMarkdown(file) {
return path.extname(file).toLowerCase() === ".md" && path.basename(file).toLowerCase() !== "readme.md";
}
function countContentMarkdownFiles(dir) {
return walkFiles(dir).filter(isContentMarkdown).length;
}
function countBySubdir(dir) {
if (!fs.existsSync(dir)) return {};
const result = {};
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
result[entry.name] = countContentMarkdownFiles(path.join(dir, entry.name));
}
return result;
}
const summary = {
totalUnits: countContentMarkdownFiles(path.join(root, "02-内容单元库")),
unitBreakdown: countBySubdir(path.join(root, "02-内容单元库")),
themeMaps: countContentMarkdownFiles(path.join(root, "05-主题地图")),
assemblies: countContentMarkdownFiles(path.join(root, "06-选题装配")),
totalFolders: walkFiles(root).filter((file) => false).length,
};
function countDirs(dir) {
if (!fs.existsSync(dir)) return 0;
let count = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
count += 1;
count += countDirs(path.join(dir, entry.name));
}
return count;
}
summary.totalFolders = countDirs(root);
summary.totalFiles = walkFiles(root).length;
console.log(JSON.stringify(summary, null, 2));