@jerryjiao/knowflow 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/README.zh-CN.md +176 -0
- package/SECURITY.md +18 -0
- package/bin/knowflow.js +367 -0
- package/docs/README-CN.md +10 -0
- package/docs/architecture/system-architecture.md +150 -0
- package/docs/assets/knowflow-graph-demo.png +0 -0
- package/docs/assets/knowflow-social-preview.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/contributing.md +131 -0
- package/docs/methodology/llm-wiki-methodology.md +110 -0
- package/docs/reference/data-model.md +200 -0
- package/examples/quickstart.md +60 -0
- package/package.json +57 -0
- package/scripts/batch-ingest.cjs +455 -0
- package/scripts/bookmark_sync.sh +150 -0
- package/scripts/enrich-wiki.js +683 -0
- package/scripts/graph_builder.py +612 -0
- package/scripts/graph_relation_labeler.py +191 -0
- package/scripts/ingest.sh +162 -0
- package/scripts/pipeline.sh +73 -0
- package/scripts/tags-builder.mjs +75 -0
- package/scripts/vector-store.mjs +717 -0
- package/scripts/vector_store.py +225 -0
- package/scripts/wechat_sync.sh +199 -0
- package/scripts/wiki-auto-fix.sh +238 -0
- package/scripts/wiki-health.py +285 -0
- package/scripts/wiki-health.sh +335 -0
- package/templates/comparison.md +31 -0
- package/templates/concept.md +37 -0
- package/templates/entity.md +32 -0
- package/templates/source.md +36 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Wiki Batch Ingest Engine
|
|
4
|
+
* Processes 700 raw files → clustered → wiki pages
|
|
5
|
+
*/
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const PROJECT_ROOT = path.resolve(process.env.KNOWFLOW_ROOT || path.join(__dirname, '..'));
|
|
10
|
+
const RAW_DIR = path.join(path.resolve(process.env.KNOWFLOW_RAW_DIR || path.join(PROJECT_ROOT, 'raw')), 'web');
|
|
11
|
+
const WIKI_DIR = path.resolve(process.env.KNOWFLOW_WIKI_DIR || path.join(PROJECT_ROOT, 'wiki'));
|
|
12
|
+
const SOURCES_DIR = path.join(WIKI_DIR, 'sources');
|
|
13
|
+
const ENTITIES_DIR = path.join(WIKI_DIR, 'entities');
|
|
14
|
+
const CONCEPTS_DIR = path.join(WIKI_DIR, 'concepts');
|
|
15
|
+
|
|
16
|
+
// Ensure dirs exist
|
|
17
|
+
[SOURCES_DIR, ENTITIES_DIR, CONCEPTS_DIR].forEach(d => {
|
|
18
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Top-level error boundary
|
|
22
|
+
process.on('uncaughtException', (err) => {
|
|
23
|
+
console.error(`\n❌ batch-ingest fatal error: ${err.message}`);
|
|
24
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Step 1: Read all files and deduplicate
|
|
29
|
+
console.log('=== Step 1: Scanning and deduplicating 700 files ===');
|
|
30
|
+
const allFiles = fs.readdirSync(RAW_DIR).filter(f => f.endsWith('.md'));
|
|
31
|
+
console.log(`Total files: ${allFiles.length}`);
|
|
32
|
+
|
|
33
|
+
// Group by URL to deduplicate
|
|
34
|
+
const urlMap = new Map(); // url -> best file
|
|
35
|
+
const emptyFiles = [];
|
|
36
|
+
const noUrlFiles = [];
|
|
37
|
+
|
|
38
|
+
for (const f of allFiles) {
|
|
39
|
+
const fp = path.join(RAW_DIR, f);
|
|
40
|
+
const content = fs.readFileSync(fp, 'utf-8');
|
|
41
|
+
const lines = content.split('\n');
|
|
42
|
+
|
|
43
|
+
// Extract URL
|
|
44
|
+
let url = '';
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const uMatch = line.match(/(?:^source:|^url:)\s*(https?:\/\/\S+)/i);
|
|
47
|
+
if (uMatch) { url = uMatch[1].trim(); break; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Extract title
|
|
51
|
+
let title = '';
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const tMatch = line.match(/^title:\s*"(.+)"/);
|
|
54
|
+
if (tMatch) { title = tMatch[1]; break; }
|
|
55
|
+
}
|
|
56
|
+
if (!title) {
|
|
57
|
+
// Try first meaningful line after frontmatter
|
|
58
|
+
for (let i = 0; i < Math.min(20, lines.length); i++) {
|
|
59
|
+
if (lines[i].startsWith('# ')) {
|
|
60
|
+
title = lines[i].replace('# ', '').trim();
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!title) title = f.replace('.md', '');
|
|
66
|
+
|
|
67
|
+
const stat = fs.statSync(fp);
|
|
68
|
+
const key = url || `no-url-${f}`;
|
|
69
|
+
|
|
70
|
+
// Skip empty/small files (< 100 bytes = likely bookmarks)
|
|
71
|
+
if (stat.size < 150) {
|
|
72
|
+
emptyFiles.push({ f, size: stat.size });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const existing = urlMap.get(key);
|
|
77
|
+
if (!existing) {
|
|
78
|
+
urlMap.set(key, { f, url, title, content, size: stat.size });
|
|
79
|
+
} else {
|
|
80
|
+
// Keep the one with better title (Chinese preferred) or larger size
|
|
81
|
+
if (existing.title.match(/[\u4e00-\u9fa5]/) && !title.match(/[\u4e00-\u9fa5]/)) {
|
|
82
|
+
// keep existing (has Chinese title)
|
|
83
|
+
} else if (!existing.title.match(/[\u4e00-\u9fa5]/) && title.match(/[\u4e00-\u9fa5]/)) {
|
|
84
|
+
urlMap.set(key, { f, url, title, content, size: stat.size });
|
|
85
|
+
} else if (stat.size > existing.size) {
|
|
86
|
+
urlMap.set(key, { f, url, title, content, size: stat.size });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const uniqueFiles = Array.from(urlMap.values());
|
|
92
|
+
console.log(`Unique files (after dedup): ${uniqueFiles.length}`);
|
|
93
|
+
console.log(`Empty/small files skipped: ${emptyFiles.length}`);
|
|
94
|
+
|
|
95
|
+
// Step 2: Cluster by topic using keyword matching
|
|
96
|
+
console.log('\n=== Step 2: Clustering by topic ===');
|
|
97
|
+
|
|
98
|
+
const TOPIC_KEYWORDS = {
|
|
99
|
+
'openclaw-claude-code-skills': {
|
|
100
|
+
name: 'OpenClaw / Claude Code / Skills 生态',
|
|
101
|
+
keywords: ['claude code', 'openclaw', 'skill', 'codex', 'subagent', 'clau', 'agent sdk', 'auto dream', 'no flicker', 'monitor', 'ultraplan', 'oop', 'token消耗', 'CLAUDE.md'],
|
|
102
|
+
entities: ['Claude-Code', 'OpenClaw', 'Codex', 'Anthropic', 'OpenAI'],
|
|
103
|
+
concepts: ['ai编程代理', 'skill系统设计', 'token优化', '多agent协作']
|
|
104
|
+
},
|
|
105
|
+
'content-automation': {
|
|
106
|
+
name: '内容自动化(小红书/公众号)',
|
|
107
|
+
keywords: ['小红书', '公众号', 'wewrite', 'wechat', 'md2wechat', '排版', '发文', 'redbox', 'redclaw', '内容生产', '飞书', 'claude-to-im', 'markcopy', '墨滴', '壹伴'],
|
|
108
|
+
entities: ['WeWrite', 'RedBox', 'RedClaw', '飞书', '微信公众号'],
|
|
109
|
+
concepts: ['内容资产工作流', '自动化运营', '公众号自动化']
|
|
110
|
+
},
|
|
111
|
+
'cloudflare-devops': {
|
|
112
|
+
name: 'Cloudflare / DevOps 工具',
|
|
113
|
+
keywords: ['cloudflare', 'workers', 'r2', 'dns', 'smail', '邮箱', 'tailscale', 'ssh', 'deploy', 'edge tunnel', '梯子', '翻墙', 'giffgaff', '手机号', 'cf_'],
|
|
114
|
+
entities: ['Cloudflare', 'Tailscale', 'Giffgaff'],
|
|
115
|
+
concepts: ['devops自动化', '免费云服务']
|
|
116
|
+
},
|
|
117
|
+
'ai-video-media': {
|
|
118
|
+
name: 'AI 视频 / 短剧 / 多媒体',
|
|
119
|
+
keywords: ['短剧', '视频', 'huobao-drama', 'videolingo', 'one take', '剪辑', 'whisper', 'tts', 'index tts', 'remotion', '电影解说', '影视', '去水印'],
|
|
120
|
+
entities: ['huobao-drama', 'VideoLingo', 'One-Take', 'IndexTTS'],
|
|
121
|
+
concepts: ['ai视频生成', '自动化内容生产']
|
|
122
|
+
},
|
|
123
|
+
'ai-agent-frameworks': {
|
|
124
|
+
name: 'AI Agent 框架与架构',
|
|
125
|
+
keywords: ['agent框架', 'agent架构', 'clawteam', 'deerflow', 'superagent', 'openspace', 'crewai', 'opc', '一人公司', 'page-agent', 'composio', 'multi-agent', '团队协作', '进化引擎'],
|
|
126
|
+
entities: ['ClawTeam', 'DeerFlow', 'OpenSpace', 'CrewAI', 'Composio', 'OpenMAIC', 'Accio-Work'],
|
|
127
|
+
concepts: ['ai-agent架构', '多agent协作', '自动进化引擎']
|
|
128
|
+
},
|
|
129
|
+
'token-cost-accounts': {
|
|
130
|
+
name: 'Token 成本 / 账号管理',
|
|
131
|
+
keywords: ['token', 'codex proxy', '注册', '批量', '账号', '封号', '封禁', '轮询', '中转', '返佣', '订阅', 'copilot', '性价比', 'ultraplan', '免费', '学生优惠', '50元', '100美金'],
|
|
132
|
+
entities: ['Codex-Proxy', 'GitHub-Copilot', 'AutoTeam'],
|
|
133
|
+
concepts: ['token成本优化', '账号自动化管理']
|
|
134
|
+
},
|
|
135
|
+
'seo-geo': {
|
|
136
|
+
name: 'SEO / GEO / 出海工具',
|
|
137
|
+
keywords: ['seo', 'geo', 'gsc', '搜索引擎', '可见度', '出海', 'xcrawl', '抓取', '数据采集', 'last30days', '风口', '赚钱线索', '信息差'],
|
|
138
|
+
entities: ['XCrawl', 'GEO-Tool', 'last30days-skill'],
|
|
139
|
+
concepts: ['seo优化', 'geo策略', '出海需求挖掘']
|
|
140
|
+
},
|
|
141
|
+
'health-wearable': {
|
|
142
|
+
name: '健康 / 可穿戴设备 + AI',
|
|
143
|
+
keywords: ['apple watch', '健康', '心梗', '猝死', 'obsidian模板', '健身', '皮质醇', '前额叶', '营养', '保健品'],
|
|
144
|
+
entities: ['Apple-Watch', 'Obsidian'],
|
|
145
|
+
concepts: ['ai健康管理', '可穿戴数据分析']
|
|
146
|
+
},
|
|
147
|
+
'ai-business-money': {
|
|
148
|
+
name: 'AI 变现 / 商业模式',
|
|
149
|
+
keywords: ['变现', '副业', '赚钱', '小生意', '一人公司', '独立开发', '出海指南', 'opcmethodology', 'opc', '血泪教训', '起号', '月入', '日入', '信息差套利', '发卡'],
|
|
150
|
+
entities: ['OPC-Methodology', 'AI副业手册'],
|
|
151
|
+
concepts: ['ai变现模式', '独立开发出海', '一人公司方法论']
|
|
152
|
+
},
|
|
153
|
+
'web-dev-tools': {
|
|
154
|
+
name: 'Web 开发 / UI 工具',
|
|
155
|
+
keywords: ['react', 'next.js', 'sveltekit', 'fasthtml', 'tldraw', 'magicui', '组件库', '画布', 'sdk', 'vibe design', 'stitchui', 'design', 'landing page', 'ppt', '幻灯片'],
|
|
156
|
+
entities: ['tldraw', 'MagicUI', 'FastHTML', 'nexu'],
|
|
157
|
+
concepts: ['前端开发工具', 'ui组件库']
|
|
158
|
+
},
|
|
159
|
+
'productivity-learning': {
|
|
160
|
+
name: '效率工具 / 学习方法',
|
|
161
|
+
keywords: ['notebooklm', 'anki', '语言学习', '记忆', 'mem9', '龙虾导航', '知识库', '论文', '旅游攻略', 'gstack', 'office-hours', 'gbrain', 'infocard', '信息卡'],
|
|
162
|
+
entities: ['NotebookLM', 'mem9', '龙虾导航', 'gstack', 'GBrain', 'Garry-Tan'],
|
|
163
|
+
concepts: ['个人知识管理', 'ai辅助学习']
|
|
164
|
+
},
|
|
165
|
+
'ai-news-updates': {
|
|
166
|
+
name: 'AI 行业动态 / 产品更新',
|
|
167
|
+
keywords: ['anthropic开源', '发布', '更新', 'v0.1', 'v1.3', '新功能', '推出', '上线', '开源了', '飙到', 'star', 'github', 'coze', '扣子', '字节', '阿里', 'deepseek', 'minimax', '李继刚', '宝玉'],
|
|
168
|
+
entities: ['ByteDance-Coze', 'MiniMax', 'DeepSeek', '李继刚', '宝玉-dotey'],
|
|
169
|
+
concepts: ['ai行业趋势', '开源ai工具生态']
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function classifyFile(file) {
|
|
174
|
+
const text = `${file.title} ${file.content.slice(0, 500)}`.toLowerCase();
|
|
175
|
+
const scores = {};
|
|
176
|
+
|
|
177
|
+
for (const [topicId, topic] of Object.entries(TOPIC_KEYWORDS)) {
|
|
178
|
+
let score = 0;
|
|
179
|
+
for (const kw of topic.keywords) {
|
|
180
|
+
if (text.includes(kw.toLowerCase())) {
|
|
181
|
+
score += kw.length; // longer keywords = more specific = higher weight
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (score > 0) scores[topicId] = score;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Return best match or 'other'
|
|
188
|
+
if (Object.keys(scores).length === 0) return 'other';
|
|
189
|
+
return Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const clusters = {};
|
|
193
|
+
for (const f of uniqueFiles) {
|
|
194
|
+
const topic = classifyFile(f);
|
|
195
|
+
if (!clusters[topic]) clusters[topic] = [];
|
|
196
|
+
clusters[topic].push(f);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log('\nTopic distribution:');
|
|
200
|
+
for (const [topicId, files] of Object.entries(clusters).sort((a,b) => b[1].length - a[1].length)) {
|
|
201
|
+
const topicName = TOPIC_KEYWORDS[topicId]?.name || topicId;
|
|
202
|
+
console.log(` [${topicName}] ${files.length} files`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Step 3: Generate wiki pages per cluster
|
|
206
|
+
console.log('\n=== Step 3: Generating Wiki pages ===');
|
|
207
|
+
|
|
208
|
+
const createdSources = [];
|
|
209
|
+
const createdEntities = new Set();
|
|
210
|
+
const createdConcepts = new Set();
|
|
211
|
+
const entityPages = {}; // entityName -> content fragments
|
|
212
|
+
const conceptPages = {}; // conceptName -> content fragments
|
|
213
|
+
|
|
214
|
+
// Load existing entities/concepts to append
|
|
215
|
+
function loadExisting(dir) {
|
|
216
|
+
const existing = {};
|
|
217
|
+
if (fs.existsSync(dir)) {
|
|
218
|
+
for (const f of fs.readdirSync(dir)) {
|
|
219
|
+
if (f.endsWith('.md')) {
|
|
220
|
+
existing[f] = fs.readFileSync(path.join(dir, f), 'utf-8');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return existing;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const existingEntities = loadExisting(ENTITIES_DIR);
|
|
228
|
+
const existingConcepts = loadExisting(CONCEPTS_DIR);
|
|
229
|
+
|
|
230
|
+
// Copy existing into our page trackers
|
|
231
|
+
for (const [name, content] of Object.entries(existingEntities)) {
|
|
232
|
+
entityPages[name] = content;
|
|
233
|
+
createdEntities.add(name);
|
|
234
|
+
}
|
|
235
|
+
for (const [name, content] of Object.entries(existingConcepts)) {
|
|
236
|
+
conceptPages[name] = content;
|
|
237
|
+
createdConcepts.add(name);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const today = '2026-04-26';
|
|
241
|
+
|
|
242
|
+
for (const [topicId, files] of Object.entries(clusters)) {
|
|
243
|
+
const topicInfo = TOPIC_KEYWORDS[topicId];
|
|
244
|
+
const topicName = topicInfo?.name || topicId;
|
|
245
|
+
|
|
246
|
+
console.log(`\nProcessing [${topicName}] with ${files.length} files...`);
|
|
247
|
+
|
|
248
|
+
// === Create Source Page ===
|
|
249
|
+
const sourceTitle = `${today}-batch-${topicId}`;
|
|
250
|
+
const sourceLines = [`# ${topicName}(批量收录)`, '', `## 来源`, '', `原始素材 ${files.length} 篇,来自 Twitter/X 平台。`, '', `## 收录文件列表`, ''];
|
|
251
|
+
|
|
252
|
+
for (const f of files.slice(0, 30)) { // List up to 30 per source
|
|
253
|
+
sourceLines.push('- **' + f.title + '** (' + (f.url || f.f) + ')');
|
|
254
|
+
}
|
|
255
|
+
if (files.length > 30) {
|
|
256
|
+
sourceLines.push(`- ... 以及其他 ${files.length - 30} 篇`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
sourceLines.push('', `## 关键主题`, '');
|
|
260
|
+
|
|
261
|
+
// Extract key themes from titles
|
|
262
|
+
const themes = new Set();
|
|
263
|
+
for (const f of files) {
|
|
264
|
+
// Simple theme extraction from title
|
|
265
|
+
const words = f.title.replace(/[^\w\u4e00-\u9fa5\s]/g, ' ').split(/\s+/).filter(w => w.length >= 2);
|
|
266
|
+
words.forEach(w => themes.add(w));
|
|
267
|
+
}
|
|
268
|
+
const themeList = Array.from(themes).slice(0, 15);
|
|
269
|
+
sourceLines.push(...themeList.map(t => `- ${t}`));
|
|
270
|
+
|
|
271
|
+
sourceLines.push('', `## 涉及实体`, '');
|
|
272
|
+
const topicEntities = (topicInfo?.entities || []).filter(e => e && e.trim());
|
|
273
|
+
for (const e of topicEntities) {
|
|
274
|
+
sourceLines.push `- [[entities/${e}]]`;
|
|
275
|
+
createdEntities.add(`${e}.md`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
sourceLines.push('', `## 涉及概念`, '');
|
|
279
|
+
const topicConcepts = (topicInfo?.concepts || []).filter(c => c && c.trim());
|
|
280
|
+
for (const c of topicConcepts) {
|
|
281
|
+
sourceLines.push `- [[concepts/${c}]]`;
|
|
282
|
+
createdConcepts.add(`${c}.md`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
sourceLines.push('', `_批量 ingest 于 ${today}_`, '');
|
|
286
|
+
|
|
287
|
+
const sourcePath = path.join(SOURCES_DIR, `${sourceTitle}.md`);
|
|
288
|
+
fs.writeFileSync(sourcePath, sourceLines.join('\n'));
|
|
289
|
+
createdSources.push(sourceTitle);
|
|
290
|
+
|
|
291
|
+
// === Update Entity Pages ===
|
|
292
|
+
for (const entityName of topicEntities) {
|
|
293
|
+
const entityFile = `${entityName}.md`;
|
|
294
|
+
if (!entityPages[entityFile]) {
|
|
295
|
+
// Create new entity page
|
|
296
|
+
const lines = [
|
|
297
|
+
`# ${entityName.replace(/-/g, ' ')}`,
|
|
298
|
+
'',
|
|
299
|
+
`## 类型`,
|
|
300
|
+
`待分类`,
|
|
301
|
+
'',
|
|
302
|
+
`## 信息`,
|
|
303
|
+
''
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
// Determine type
|
|
307
|
+
if (['Claude-Code','Codex','OpenClaw','GitHub-Copilot','WeWrite','RedBox','RedClaw','XCrawl','GEO-Tool','NotebookLM','mem9','tldraw','MagicUI','FastHTML','nexu','huobao-drama','VideoLingo','One-Take','IndexTTS','GBrain','gstack','Codex-Proxy','AutoTeam','OPC-Methodology','AI副业手册','DeerFlow','ClawTeam','OpenSpace','CrewAI','Composio','OpenMAIC','Accio-Work','ByteDance-Coze','Apple-Watch','Tailscale','Giffgaff','龙虾导航'].includes(entityName)) {
|
|
308
|
+
lines[2] = '产品/项目';
|
|
309
|
+
} else if (['Anthropic','OpenAI','DeepSeek','MiniMax','ByteDance','Cloudflare'].includes(entityName)) {
|
|
310
|
+
lines[2] = '公司/组织';
|
|
311
|
+
} else if (['宝玉-dotey','李继刚','Garry-Tan'].includes(entityName)) {
|
|
312
|
+
lines[2] = '人物';
|
|
313
|
+
} else if (['飞书','微信公众号'].includes(entityName)) {
|
|
314
|
+
lines[2] = '平台';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
entityPages[entityFile] = lines.join('\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Append info from this batch
|
|
321
|
+
const relevantFiles = files.filter(f =>
|
|
322
|
+
f.title.toLowerCase().includes(entityName.split('-')[0].toLowerCase()) ||
|
|
323
|
+
f.content.slice(0, 300).toLowerCase().includes(entityName.split('-')[0].toLowerCase())
|
|
324
|
+
).slice(0, 5);
|
|
325
|
+
|
|
326
|
+
if (relevantFiles.length > 0) {
|
|
327
|
+
const infoLines = relevantFiles.map(f =>
|
|
328
|
+
'- **' + f.title + '** — 来源: [[sources/' + sourceTitle + ']] (EXTRACTED)'
|
|
329
|
+
);
|
|
330
|
+
entityPages[entityFile] += `\n### ${today} 批量收录\n${infoLines.join('\n')}\n`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// === Update Concept Pages ===
|
|
335
|
+
for (const conceptName of topicConcepts) {
|
|
336
|
+
const conceptFile = `${conceptName}.md`;
|
|
337
|
+
if (!conceptPages[conceptFile]) {
|
|
338
|
+
const lines = [
|
|
339
|
+
`# ${conceptName}`,
|
|
340
|
+
'',
|
|
341
|
+
`## 定义`,
|
|
342
|
+
`(待补充)`,
|
|
343
|
+
'',
|
|
344
|
+
`## 来源与视角`,
|
|
345
|
+
''
|
|
346
|
+
];
|
|
347
|
+
conceptPages[conceptFile] = lines.join('\n');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Add perspective from this batch
|
|
351
|
+
conceptPages[conceptFile] += `\n### ${topicName}视角 (${today})\n\n基于 ${files.length} 篇素材,核心观点:\n\n- 来自 [[sources/${sourceTitle}]] 的综合分析 (INFERRED)\n`;
|
|
352
|
+
|
|
353
|
+
// Link entities
|
|
354
|
+
if (!conceptPages[conceptFile].includes('## 关联实体')) {
|
|
355
|
+
conceptPages[conceptFile] += `\n## 关联实体\n`;
|
|
356
|
+
}
|
|
357
|
+
for (const e of topicEntities) {
|
|
358
|
+
if (!conceptPages[conceptFile].includes(`[[entities/${e}]]`)) {
|
|
359
|
+
conceptPages[conceptFile] += `- [[entities/${e}]]\n`;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Write all entity pages
|
|
366
|
+
console.log('\n=== Writing Entity pages ===');
|
|
367
|
+
for (const [name, content] of Object.entries(entityPages)) {
|
|
368
|
+
fs.writeFileSync(path.join(ENTITIES_DIR, name), content);
|
|
369
|
+
}
|
|
370
|
+
console.log(`Entity pages written: ${Object.keys(entityPages).length}`);
|
|
371
|
+
|
|
372
|
+
// Write all concept pages
|
|
373
|
+
console.log('\n=== Writing Concept pages ===');
|
|
374
|
+
for (const [name, content] of Object.entries(conceptPages)) {
|
|
375
|
+
fs.writeFileSync(path.join(CONCEPTS_DIR, name), content);
|
|
376
|
+
}
|
|
377
|
+
console.log(`Concept pages written: ${Object.keys(conceptPages).length}`);
|
|
378
|
+
|
|
379
|
+
// Step 4: Update index.md
|
|
380
|
+
console.log('\n=== Step 4: Updating index.md ===');
|
|
381
|
+
const indexPath = path.join(WIKI_DIR, 'index.md');
|
|
382
|
+
let indexContent = '';
|
|
383
|
+
if (fs.existsSync(indexPath)) {
|
|
384
|
+
indexContent = fs.readFileSync(indexPath, 'utf-8');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
indexContent += `\n## ${today} 批量 Ingest(700 文件处理)\n\n`;
|
|
388
|
+
for (const [topicId, files] of Object.entries(clusters).sort((a,b) => b[1].length - a[1].length)) {
|
|
389
|
+
const topicName = TOPIC_KEYWORDS[topicId]?.name || topicId;
|
|
390
|
+
const sourceTitle = `${today}-batch-${topicId}`;
|
|
391
|
+
indexContent += `- **${topicName}** (${files.length}篇) — [[sources/${sourceTitle}]]\n`;
|
|
392
|
+
}
|
|
393
|
+
indexContent += '\n';
|
|
394
|
+
|
|
395
|
+
fs.writeFileSync(indexPath, indexContent);
|
|
396
|
+
|
|
397
|
+
// Step 5: Update log.md
|
|
398
|
+
console.log('=== Step 5: Updating log.md ===');
|
|
399
|
+
const logPath = path.join(WIKI_DIR, 'log.md');
|
|
400
|
+
let logContent = '';
|
|
401
|
+
if (fs.existsSync(logPath)) {
|
|
402
|
+
logContent = fs.readFileSync(logPath, 'utf-8');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
logContent += `## [${today}] Batch Ingest — 700 Raw 文件批量编译\n\n`;
|
|
406
|
+
logContent += `- **处理文件**: ${allFiles.length} 个 raw 文件\n`;
|
|
407
|
+
logContent += `- **去重后**: ${uniqueFiles.length} 个唯一文件\n`;
|
|
408
|
+
logContent += `- **跳过空文件**: ${emptyFiles.length} 个\n`;
|
|
409
|
+
logContent += `- **新建 Source 页面**: ${createdSources.length} 个\n`;
|
|
410
|
+
logContent += `- **涉及 Entity 页面**: ${Object.keys(entityPages).length} 个\n`;
|
|
411
|
+
logContent += `- **涉及 Concept 页面**: ${Object.keys(conceptPages).length} 个\n`;
|
|
412
|
+
logContent += `- **主题分布**:\n`;
|
|
413
|
+
|
|
414
|
+
for (const [topicId, files] of Object.entries(clusters).sort((a,b) => b[1].length - a[1].length)) {
|
|
415
|
+
const topicName = TOPIC_KEYWORDS[topicId]?.name || topicId;
|
|
416
|
+
logContent += ` - ${topicName}: ${files.length}篇\n`;
|
|
417
|
+
}
|
|
418
|
+
logContent += '\n';
|
|
419
|
+
|
|
420
|
+
fs.writeFileSync(logPath, logContent);
|
|
421
|
+
|
|
422
|
+
// Final report
|
|
423
|
+
console.log('\n' + '='.repeat(60));
|
|
424
|
+
console.log('BATCH INGEST COMPLETE');
|
|
425
|
+
console.log('='.repeat(60));
|
|
426
|
+
console.log(`Total raw files: ${allFiles.length}`);
|
|
427
|
+
console.log(`After dedup: ${uniqueFiles.length}`);
|
|
428
|
+
console.log(`Empty/skipped: ${emptyFiles.length}`);
|
|
429
|
+
console.log(`Source pages created: ${createdSources.length}`);
|
|
430
|
+
console.log(`Entity pages total: ${Object.keys(entityPages).length}`);
|
|
431
|
+
console.log(`Concept pages total: ${Object.keys(conceptPages).length}`);
|
|
432
|
+
console.log(`Topic clusters: ${Object.keys(clusters).length}`);
|
|
433
|
+
console.log('');
|
|
434
|
+
|
|
435
|
+
console.log('Top entities:');
|
|
436
|
+
const entityCounts = {};
|
|
437
|
+
for (const [topicId, topic] of Object.entries(TOPIC_KEYWORDS)) {
|
|
438
|
+
for (const e of (topic.entities || [])) {
|
|
439
|
+
entityCounts[e] = (entityCounts[e] || 0) + (clusters[topicId]?.length || 0);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const [e, c] of Object.entries(entityCounts).sort((a,b) => b[1]-a[1]).slice(0, 10)) {
|
|
443
|
+
console.log(` ${e}: mentioned in ~${c} files`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
console.log('\nTop concepts:');
|
|
447
|
+
const conceptCounts = {};
|
|
448
|
+
for (const [topicId, topic] of Object.entries(TOPIC_KEYWORDS)) {
|
|
449
|
+
for (const c of (topic.concepts || [])) {
|
|
450
|
+
conceptCounts[c] = (conceptCounts[c] || 0) + (clusters[topicId]?.length || 0);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
for (const [c, cnt] of Object.entries(conceptCounts).sort((a,b) => b[1]-a[1]).slice(0, 10)) {
|
|
454
|
+
console.log(` ${c}: covered by ~${cnt} files`);
|
|
455
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# knowflow bookmark sync — X/Twitter 书签 → LLM Wiki 自动同步
|
|
3
|
+
# Usage: bash scripts/bookmark_sync.sh [--dry-run]
|
|
4
|
+
#
|
|
5
|
+
# 流程:
|
|
6
|
+
# 1. ft sync (同步最新书签到本地 SQLite)
|
|
7
|
+
# 2. ft list --json (导出新书签)
|
|
8
|
+
# 3. 对每条书签生成 raw/twitter/ 文件
|
|
9
|
+
# 4. 输出需要 Agent 处理的新书签清单
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
14
|
+
WIKI_ROOT="${KNOWFLOW_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
|
15
|
+
RAW_BASE="${KNOWFLOW_RAW_DIR:-$WIKI_ROOT/raw}"
|
|
16
|
+
RAW_DIR="$RAW_BASE/twitter"
|
|
17
|
+
STATE_FILE="$WIKI_ROOT/.bookmark-state.json"
|
|
18
|
+
TIMESTAMP=$(date +%Y-%m-%d-%H%M)
|
|
19
|
+
DRY_RUN="${1:-}"
|
|
20
|
+
|
|
21
|
+
echo "🔖 KnowFlow — Bookmark Sync"
|
|
22
|
+
echo "======================================="
|
|
23
|
+
echo "Time: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
24
|
+
echo ""
|
|
25
|
+
|
|
26
|
+
# ── Step 1: Sync bookmarks ──────────────────────────
|
|
27
|
+
echo "📥 Step 1: Syncing bookmarks from X..."
|
|
28
|
+
|
|
29
|
+
if [ "$DRY_RUN" != "--dry-run" ]; then
|
|
30
|
+
ft sync --target-adds 50 --max-minutes 5 --yes 2>&1 || echo "⚠️ Sync: no new bookmarks or error (continuing...)"
|
|
31
|
+
else
|
|
32
|
+
echo "[DRY RUN] Would run: ft sync"
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# ── Step 2: Export & process new bookmarks ───────────
|
|
36
|
+
echo ""
|
|
37
|
+
echo "📋 Step 2: Exporting new bookmarks to raw/twitter/"
|
|
38
|
+
|
|
39
|
+
if [ "$DRY_RUN" != "--dry-run" ]; then
|
|
40
|
+
mkdir -p "$RAW_DIR"
|
|
41
|
+
|
|
42
|
+
# ft list --json outputs a JSON array, save to temp then process with python
|
|
43
|
+
TMPJSON=$(mktemp)
|
|
44
|
+
ft list --json > "$TMPJSON" 2>/dev/null || true
|
|
45
|
+
|
|
46
|
+
python3 - "$TMPJSON" "$STATE_FILE" "$RAW_DIR" "$TIMESTAMP" << 'PYEOF'
|
|
47
|
+
import json, os, sys
|
|
48
|
+
from datetime import datetime
|
|
49
|
+
|
|
50
|
+
tmpjson, state_file, raw_dir, timestamp = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
|
51
|
+
|
|
52
|
+
# Load state
|
|
53
|
+
last_seen = ''
|
|
54
|
+
if os.path.exists(state_file):
|
|
55
|
+
with open(state_file) as f:
|
|
56
|
+
state = json.load(f)
|
|
57
|
+
last_seen = state.get('lastSeenId', '')
|
|
58
|
+
|
|
59
|
+
# Load bookmarks
|
|
60
|
+
with open(tmpjson) as f:
|
|
61
|
+
bookmarks = json.load(f)
|
|
62
|
+
|
|
63
|
+
os.makedirs(raw_dir, exist_ok=True)
|
|
64
|
+
new_max_id = last_seen
|
|
65
|
+
new_count = 0
|
|
66
|
+
|
|
67
|
+
for bm in bookmarks:
|
|
68
|
+
tweet_id = str(bm.get('id') or bm.get('tweetId', ''))
|
|
69
|
+
if not tweet_id: continue
|
|
70
|
+
|
|
71
|
+
# Skip already processed
|
|
72
|
+
if last_seen and tweet_id <= last_seen:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
new_count += 1
|
|
76
|
+
if not new_max_id or tweet_id > new_max_id:
|
|
77
|
+
new_max_id = tweet_id
|
|
78
|
+
|
|
79
|
+
author = bm.get('authorHandle', 'unknown')
|
|
80
|
+
text = bm.get('text', '')
|
|
81
|
+
created = bm.get('postedAt', timestamp)
|
|
82
|
+
url = bm.get('url', '')
|
|
83
|
+
|
|
84
|
+
# Metrics
|
|
85
|
+
metrics = {}
|
|
86
|
+
for k in ['likeCount','retweetCount','replyCount','bookmarkCount','viewCount']:
|
|
87
|
+
v = bm.get(k)
|
|
88
|
+
if v: metrics[k.replace('Count','').lower()] = v
|
|
89
|
+
|
|
90
|
+
# Generate markdown
|
|
91
|
+
lines = [
|
|
92
|
+
f'# Bookmark from @{author}',
|
|
93
|
+
'',
|
|
94
|
+
f'> 来源: Twitter/X | 原始链接: {url}',
|
|
95
|
+
f'> 收藏时间: {created}',
|
|
96
|
+
'',
|
|
97
|
+
f'## 📌 原文',
|
|
98
|
+
'',
|
|
99
|
+
text,
|
|
100
|
+
'',
|
|
101
|
+
]
|
|
102
|
+
if metrics:
|
|
103
|
+
lines.append('## 📊 互动数据')
|
|
104
|
+
lines.append('')
|
|
105
|
+
for label, key in [('❤️ Likes','like'), ('🔁 Retweets','retweet'), ('💬 Replies','reply'), ('👁 Views','view')]:
|
|
106
|
+
if key in metrics:
|
|
107
|
+
lines.append(f'- {label}: {metrics[key]}')
|
|
108
|
+
lines.append('')
|
|
109
|
+
|
|
110
|
+
safe_author = author.lstrip('@')
|
|
111
|
+
filename = f'{raw_dir}/{timestamp}-bookmark-{safe_author}-{tweet_id[:8]}.md'
|
|
112
|
+
with open(filename, 'w') as f:
|
|
113
|
+
f.write('\n'.join(lines))
|
|
114
|
+
preview = text[:80].replace('\n', ' ') + ('...' if len(text) > 80 else '')
|
|
115
|
+
print(f' ✅ @{author}: {preview}')
|
|
116
|
+
|
|
117
|
+
# Save state
|
|
118
|
+
if new_max_id and new_count > 0:
|
|
119
|
+
with open(state_file, 'w') as f:
|
|
120
|
+
json.dump({'lastSeenId': new_max_id, 'lastSyncAt': datetime.now().isoformat(), 'newCount': new_count}, f, indent=2)
|
|
121
|
+
print(f'\n📊 State updated: {new_count} new bookmarks, max_id={new_max_id}')
|
|
122
|
+
elif new_count == 0:
|
|
123
|
+
print(' ℹ️ No new bookmarks since last sync')
|
|
124
|
+
PYEOF
|
|
125
|
+
|
|
126
|
+
if [ $? -ne 0 ]; then
|
|
127
|
+
echo "❌ Python processing failed" >&2
|
|
128
|
+
rm -f "$TMPJSON"
|
|
129
|
+
exit 1
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
rm -f "$TMPJSON"
|
|
133
|
+
else
|
|
134
|
+
echo "[DRY RUN] Would export new bookmarks to raw/twitter/"
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# ── Step 3: Auto Pipeline (ingest new raw → wiki) ──
|
|
138
|
+
echo ""
|
|
139
|
+
echo "🔄 Step 3: Running pipeline for new content..."
|
|
140
|
+
PIPELINE_LOG=$(bash "$SCRIPT_DIR/pipeline.sh" --step=2,3,4,5 2>&1) && echo "$PIPELINE_LOG" || {
|
|
141
|
+
PIPELINE_EXIT=$?
|
|
142
|
+
echo "$PIPELINE_LOG"
|
|
143
|
+
if [ $PIPELINE_EXIT -ne 0 ]; then
|
|
144
|
+
echo "⚠️ Pipeline exited with code $PIPELINE_EXIT (may have issues to fix)"
|
|
145
|
+
fi
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# ── Summary ──────────────────────────────────────────
|
|
149
|
+
echo ""
|
|
150
|
+
echo "✅ Sync + Pipeline complete!"
|