
Dbs Content System
- 9.9k installs
- 8.9k repo stars
- Updated July 27, 2026
- dontbesilent2025/dbskill
dbs-content-system is an agent skill that audits local archives and builds a reusable content-unit engineering project with topic maps and assembly drafts.
About
The dbs-content-system skill turns large local content archives into a reusable structured engineering project instead of delivering a one-off summary. It starts in audit mode: count text files, estimate volume, classify source types, and confirm inclusion boundaries before any heavy work. Entry requires at least fifty files or eighty thousand characters and two source dimensions such as published posts, drafts, case studies, or course transcripts. After audit it scaffolds a fixed directory tree with rules, raw copy archives, unit libraries, processing state, templates, topic maps, and assembly drafts. Content is split into five unit types: QST questions, CON concepts, OPI opinions, CAS cases, and SOL solutions, each as Markdown with YAML frontmatter and Obsidian links. Work proceeds through gated modes from audit to sample to batch to full, with Node scripts for source registry, unit extraction, link maps, deduplication, and system summaries. Default delivery stops at a usable system with sample units, topic maps, and assembly drafts rather than full corpus completion.
- Audits volume and boundaries before creating the engineering project.
- Scaffolds a fixed 00-07 directory tree with rules and state files.
- Extracts QST, CON, OPI, CAS, and SOL units with YAML frontmatter.
- Runs gated audit, sample, batch, and full modes with upgrade gates.
- Ships Node scripts for registry, deduplication, links, and summaries.
Dbs Content System by the numbers
- 9,947 all-time installs (skills.sh)
- +1,799 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #48 of 2,742 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
dbs-content-system capabilities & compatibility
- Capabilities
- archive audit with volume gates · content unit extraction qst con opi cas sol · topic map and assembly draft generation · dedup and relationship indexing
- Works with
- obsidian · github
- Use cases
- documentation · copywriting · planning · orchestration
- Runs
- Runs locally
- Pricing
- Free
What dbs-content-system says it does
你交付的不是一份总结,而是一套能继续运转的系统。
原则 1:先审计,再建工程
原则 3:原始素材不改写,只复制副本
原则 4:对象不是文件,而是内容单元
npx skills add https://github.com/dontbesilent2025/dbskill --skill dbs-content-systemAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9.9k |
|---|---|
| repo stars | ★ 8.9k |
| Last updated | July 27, 2026 |
| Repository | dontbesilent2025/dbskill ↗ |
How do I turn piles of drafts, posts, and course files into a reusable local content system agents can keep extending?
Audit a large local content archive and scaffold a gated content-unit engineering project with topic maps and assembly drafts.
Who is it for?
Creators with large mixed archives who want Obsidian-linked units and gated batch extraction instead of one-off summaries.
Skip if: Single-article polish, title tweaks, or archives below fifty files or eighty thousand characters without clear boundaries.
When should I use this skill?
User triggers /dbs-content-system or asks to build a content structuring system, turn archives into reusable assets, or set up topic maps and assembly drafts.
What you get
A scaffolded local project with copied raw sources, typed content units, topic maps, assembly drafts, and runnable registry and deduplication indexes.
- Engineering scaffold with 00-07 folders
- First content units, topic maps, and assembly drafts
By the numbers
- 5 content unit types
- 4 relationship types
- 4 processing modes
Files
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-content、/dbs-hook、/dbs-xhs-title 或 /dbs-ai-check。
---
审计门槛
只有满足以下条件,才进入正式建工程。
数量门槛
满足以下任一条即可:
- 可处理文本文件不少于
50个 - 或可提取正文总字数不少于
80000字
来源维度门槛
至少命中以下 2 类:
- 本人内容
- 外部研究素材
- 多作者内容
- 多平台内容
边界门槛
用户必须至少说明:
- 哪些目录是这次要纳入的
- 哪些目录明确不纳入
- 当前优先处理什么类型内容
默认优先处理顺序:
1. 用户本人已发布内容 2. 用户本人未发布但较成熟的稿件 3. 外部研究素材
如果不满足门槛:
- 不创建完整工程
- 输出一份审计结论
- 说明为什么当前不适合做重工程
- 给出降级路径:轻量索引、先做小样本、或先收缩边界
---
默认输出位置
目录优先级
1. 用户明确指定新目录:用用户指定目录 2. 用户只给内容根目录、未给输出位置:在当前工作目录下新建 3. 当前目录明显不适合建工程:要求用户指定位置
工程命名
默认目录名:
内容结构化系统
如果用户明确给了项目名,沿用用户命名。
如果重名,追加日期后缀:
内容结构化系统_YYYYMMDD
---
标准工程结构
审计通过后,固定建立以下结构:
{工程根}/
├── 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.mdscaffold/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
最小字段
每个内容单元至少包含:
idtypetitlecanonicalversionsource_documentsrelationships
关系类型
第一期只允许 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个主问题单元QST1个主观点单元OPI- 如文中有稳定定义,再抽
CON - 如文中有具体事件、数据或案例,再抽
CAS - 如文中有明确动作路径,再抽
SOL
4. 每个新单元都必须补齐:
source_documentsthemeskeywordsrelationships
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.jsnode 07-脚本与工具/rebuild-processing-ledger.jsnode 07-脚本与工具/extract-sample-units.js --helpnode 07-脚本与工具/assemble-topic-from-units.js --title '示例选题' --question ... --concept ... --opinion ... --case ... --solution ...node 07-脚本与工具/generate-link-map.jsnode 07-脚本与工具/generate-duplicate-candidates.jsnode 07-脚本与工具/fill-obsidian-links.jsnode 07-脚本与工具/summarize-system.js
Phase 7:批量推进与全量推进
只有样本模式闸门通过,才进入这里。
批量模式
- 按批次推进,不是一口气吃完整库
- 每批处理固定数量素材
- 每批素材先过来源分类器,再决定是跳过、归一化还是进入抽取
- 每批结束后必须复盘:字段是否改动、关系是否改动、去重是否失控、返工量是否异常
批量模式 → 全量模式 升档闸门
必须同时满足:
- 连续
2个批次处理后,没有改字段规范 - 连续
2个批次处理后,没有改关系规则 - 连续
2个批次处理后,没有改去重规则 - 连续
2个批次处理后,没有出现大面积返工 - 每批处理结束后,都能直接续跑下一批,不需要重建工程
- 人工抽查
30个内容单元,重大误判不超过3个 - 去重候选没有失控堆积
只有这些条件全部成立,才允许进入全量模式。
全量模式
- 对剩余待处理库存持续推进
- 以既有规则滚动扩展覆盖率
- 全量推进也必须保留「分类 → 归一化 → 抽取」链路,不得把所有文件重新降级成统一抽取入口
- 不得在全量模式里重新发明字段、关系或去重类型
---
可用态判定
只有同时满足以下条件,才可以说「系统能用了」:
- 完整工程骨架已建立
- 规则文件已写入
- 原始素材副本已复制
- 来源注册表、原始素材索引、待处理清单已存在
- 已抽取首批内容单元
- 已出现主题地图
- 已出现选题装配稿
- 已生成关系与去重索引
03-处理状态/处理状态总览.md已明确当前范围、未处理量与下一步入口
默认交付到这里即可,不承诺首次全量结构化完成。
---
对话与执行要求
- 不要停留在建议层
- 不要只给目录结构草图
- 用户已授权执行时,直接动手
- 每做完一个阶段,都要告诉用户当前完成到了哪一层
- 发现素材规模不足,直接指出,不要假装可以靠方法论弥补素材量
- 发现输入边界混乱,先收缩边界,再继续
---
与其他 skill 的关系
适合转入本 skill
/dbs-good-question已把问题说明书写清楚,且适合自动化执行/dbs-agent-migration已经把 Agent 工作台迁好,下一步要搭内容工程- 用户明确需要本地内容资产长期工程化
本 skill 内部完成后可推荐
- 需要继续诊断某个具体选题 →
/dbs-content - 需要给结构化系统补单篇内容方法 →
/dbs-content - 需要判断新节点是否值得升级为长期规律 →
/dbs-decision - 想把一次结构化工程的结论存档 →
/dbs-save
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
关系有效
运行:
node 07-脚本与工具/generate-link-map.js之后:
03-处理状态/关系索引.csv必须存在03-处理状态/关系总览.md必须存在- 目标缺失数必须可见
去重可审计
运行:
node 07-脚本与工具/generate-duplicate-candidates.js之后:
03-处理状态/去重候选索引.csv必须存在03-处理状态/去重与冲突总览.md必须存在03-处理状态/去重与冲突审计.csv必须存在
Obsidian 可见
运行:
node 07-脚本与工具/fill-obsidian-links.js之后:
- 内容单元正文
## 关联单元里应补入[[文件名]] - 主题地图与装配稿可以在
Obsidian里直接点开
装配不是空壳
- 主题地图
主题定义不能继续停留在待人工补全 - 选题装配稿
目标受众 / 装配理由 / 建议结构 / 表达骨架不能全部为空 - 装配稿应能说明这篇内容为什么值得这样组,而不是只列 5 个单元文件名
- 同一主题下应能看到补充问题、补充观点、补充案例、补充方案,证明它不是一次性装配
自动推荐可以重组
- 运行
assemble-topic-from-units.js --auto后,不能只回到原主题内部的同类单元 - 对宽题,自动推荐出的主问题、主观点、主方案至少有一项应体现跨主题重组
- 例如「年轻人怎么赚钱」这类题,允许主问题来自「兴趣变现」,但主观点应能切到「需求倒推 / 老板思维」主轴,而不是永远停在兴趣变现的原始判断里
- 自动推荐产出的装配稿,至少要让人看出:系统不是按文件名抓 5 个点,而是在按主题关系重新组织表达
样本覆盖面
首批正式验收,不能只验证单一文稿类型。
至少覆盖:
- 「兴趣变现 / 生产型兴趣」类
- 「找生意 / 反赛道思维」类
- 「稳定收入 / 反脆弱」类
- 「系统赚钱 / 被动收入」类
- 「需求倒推 / 老板思维」类
如果某一类仍然只能生成通用兜底标题、空泛摘要或待补字段,则不能宣布通过。
验收命令
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/正式版验收
dbs-content-system Quickstart
定位
dbs-content-system 是 dbskill 里的进阶内容工程模块。
它适合这类用户:
- 本地已经积累了大量文稿、推文、课程稿、案例素材
- 不只是想改一篇内容,而是想把旧内容变成可复用资产
- 愿意先做样本验证,再逐步推进批量结构化
它不适合这类用户:
- 只有零散素材
- 只想优化单篇内容
- 还没有明确边界就想一口气全量处理
安装
整套安装:
npx -y skills add dontbesilent2025/dbskill -g --all单独安装本模块:
npx -y skills add dontbesilent2025/dbskill --skill dbs-content-system最短启动链路
1. 初始化新工程
node tools/init-content-system.js /你的/新工程目录2. 复制首批样本文稿
把 3 到 5 篇代表性文稿复制到:
01-原始素材区/完整副本/
优先级:
- 已经有清晰标题、分段、小标题的
Markdown - 已经带有「核心观点提炼」「概念定义」「适用场景」之类区块的主稿
- 不要先拿导出版
HTML做首批验证
3. 生成来源候选与原始索引
cd /你的/新工程目录
node 07-脚本与工具/generate-source-registry.js
node 07-脚本与工具/rebuild-processing-ledger.js4. 自动抽取首批样本
node 07-脚本与工具/extract-sample-units.js --files '完整副本/路径1.md,完整副本/路径2.md,完整副本/路径3.md'首批验证不要只挑同一种稿子。
至少覆盖:
- 1 篇「兴趣变现 / 生产型兴趣」类文稿
- 1 篇「找生意 / 反赛道思维」类文稿
- 1 篇「稳定收入 / 反脆弱」类文稿
- 1 篇「系统赚钱 / 被动收入」类文稿
- 1 篇「需求倒推 / 老板思维」类文稿
这样才能看出抽取器是不是只对单一题材成立。
5. 跑校验链路
node 07-脚本与工具/generate-link-map.js
node 07-脚本与工具/generate-duplicate-candidates.js
node 07-脚本与工具/fill-obsidian-links.js
node 07-脚本与工具/summarize-system.js6. 用真实单元重组一个新选题
手工指定单元:
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'按选题标题自动推荐:
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
内容结构化系统
语言与排版
- 始终使用中文沟通
- 遵循《中文文案排版指北》:中英文之间加空格,中文与数字之间加空格,中文标点使用全角,数字使用半角,专有名词大小写正确
- 写作、改写、总结、输出文案时,中文引号默认使用全角直角引号「」
系统定位
本工程用于管理本地内容相关资产。
核心目标不是归档文稿,而是把原始文稿加工为可复用、可追溯、可重组的「内容单元」,并支持后续按选题装配为文章、视频稿、标题方案与系列内容。
目录职责
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这类泛关联而不写明确关系类型 - 不得在未判断重复前批量新建同类内容单元
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
内容结构化系统
这是一个把原始内容资产加工为「内容单元」的新工程。
先看哪里
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中跳转 - 可以基于现有内容单元,跨主题重组出新的选题装配稿
- 自动推荐不再只会回到原主题内部,已经可以给宽题拉出跨主题主轴
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. 同一案例多次讲述时,默认并入主案例,除非叙述角度已经变化成新案例
---
六、当前范围边界
当前系统优先处理以下内容:
- 用户本人已发布内容
- 用户本人未发布但结构成熟的稿件
- 外部研究素材
如果后续进入其他类型内容,先结合方法论和用户沟通结果再决定是否纳入。
内容单元关系规则
第一期只允许 4 类关系:
回应解释证明冲突
使用说明
回应
用于某个内容单元直接回应另一个问题、判断或方案。
解释
用于概念单元解释问题单元、观点单元或方案单元。
证明
用于案例单元证明观点单元、方案单元或问题判断。
冲突
用于两个内容单元在判断方向、适用边界或结论上存在直接冲突。
方向纪律
解释优先由概念单元指向被解释对象证明优先由案例单元指向被证明对象回应由回应方指向被回应对象冲突建议双向建立,并补充note
内容单元去重与版本规则
去重类型
第一期只允许 4 类:
完全重复同义重复近似重复重复讲述
处理规则
完全重复:默认合并同义重复:默认合并近似重复:不合并,保留独立单元重复讲述:视叙述角度决定是否并入主单元
主单元规则
需要合并时,必须指定 canonical: true 的主单元作为当前有效对象。
版本规则
只有语义边界、适用范围、核心结论或关键步骤发生变化,才提升 version。
内容单元字段规范
通用字段
每个内容单元必须包含以下字段:
idtypetitlesource_documentssource_authorsthemeskeywordsstatuscanonicalversioncreated_atupdated_atrelationships
类型字段
问题单元
question_textquestion_typeuser_stageapplicable_topics
概念单元
concept_definitionconcept_function
观点单元
core_claimclaim_scopewhy_it_matters
案例单元
case_subjectcase_summarycase_processcase_result
方案单元
target_problemsolution_summaryaction_stepsexpected_result
relationships 写法
空关系统一写:
relationships: []存在关系时统一写:
relationships:
- type: 解释
target: CON-20260602-001
note: 用于定义判断边界处理流程
标准顺序
1. 锁定纳入边界与排除边界 2. 初始化工程骨架 3. 复制原始素材到 01-原始素材区/完整副本/ 4. 生成来源注册候选与原始索引 5. 先按来源类型做分类:成稿、短稿、推文合集、转写稿、说明文件、中间产物 6. 对可处理来源执行归一化:切块、去噪、提轻量上下文,或直接进入成稿抽取 7. 运行抽取脚本,生成第一批内容单元草稿 8. 人工复核与补充关系 9. 生成关系索引、去重候选与系统总览 10. 建立主题地图与选题装配稿
当前默认目标
第一次交付以「系统能用了」为准,不以全量处理完成为准。
新增文稿进入系统流程
单篇新文稿标准动作
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.jsnode 07-脚本与工具/generate-duplicate-candidates.jsnode 07-脚本与工具/fill-obsidian-links.js
10. 将该原文写入 03-处理状态/已处理清单.csv,并标明是「已抽取样本」「已跳过」还是「待人工复核」 11. 在 03-处理状态/抽取日志.md 记录本次处理
来源命名规范
来源 ID 结构
统一使用:
SRC-类别代码-序号
例如:
SRC-WX-001SRC-VIDEO-011SRC-BK-002
类别代码建议
WX:公众号文章VIDEO:短视频文稿BK:爆款文稿CON:观点与概念素材EXT:外部研究素材USER:用户本人其他素材
规则
- 同一路径一旦分配来源 ID,不重复分配
- 旧 ID 已经被内容单元使用时,不随意改号
- 批量脚本生成的是候选表,正式以
来源注册表.csv为准
主题地图:主题名
主题定义
核心问题单元
核心概念单元
核心观点单元
核心案例单元
核心方案单元
常见装配路径
1. 问题: 2. 概念: 3. 观点: 4. 案例: 5. 方案:
相关主题
核心内容
来源依据
使用场景
关联单元
备注
核心内容
来源依据
使用场景
关联单元
备注
核心内容
来源依据
使用场景
关联单元
备注
核心内容
来源依据
使用场景
关联单元
备注
选题装配:标题
目标受众
装配理由
核心调用单元
问题
概念
观点
案例
方案
可追加调用单元
- 补充问题:
- 补充概念:
- 补充观点:
- 补充案例:
- 补充方案:
建议结构
1. 痛点: 2. 冲突: 3. 展开: 4. 案例: 5. 方法: 6. 收束:
表达骨架
开头
中段 1
中段 2
中段 3
结尾
核心内容
来源依据
使用场景
关联单元
备注
#!/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);
#!/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));
#!/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));
#!/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));
#!/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));
#!/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);
#!/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));
#!/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));
#!/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));
Related skills
FAQ
What minimum archive size does dbs-content-system require?
At least fifty text files or eighty thousand characters, plus two source dimensions and explicit include or exclude directories.
Does dbs-content-system rewrite original source files?
No. Original files stay untouched; processing uses copies under 01-原始素材区/完整副本/ inside the new project.
What unit types does the first release extract?
Five types: QST questions, CON concepts, OPI opinions, CAS cases, and SOL solutions as independent Markdown files.