@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,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
KnowFlow Vector Store — 基于智谱 embedding-3 的语义检索
|
|
4
|
+
用法:
|
|
5
|
+
python3 vector_store.py build # 对所有 wiki 页面建索引
|
|
6
|
+
python3 vector_store.py query "AI Agent" # 语义查询
|
|
7
|
+
python3 vector_store.py stats # 查看索引状态
|
|
8
|
+
"""
|
|
9
|
+
import os, sys, json, glob, hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
PROJECT_ROOT = Path(os.environ.get("KNOWFLOW_ROOT", Path(__file__).resolve().parent.parent))
|
|
13
|
+
WIKI_DIR = Path(os.environ.get("KNOWFLOW_WIKI_DIR", PROJECT_ROOT / "wiki")).resolve()
|
|
14
|
+
INDEX_FILE = WIKI_DIR / ".vector-index.json"
|
|
15
|
+
EMBED_CACHE = WIKI_DIR / ".embed-cache.json"
|
|
16
|
+
|
|
17
|
+
# ====== Embedding ======
|
|
18
|
+
def get_embedding(text: str, api_key: str = None) -> list:
|
|
19
|
+
"""调用智谱 embedding-3 API 获取向量"""
|
|
20
|
+
import urllib.request, urllib.error
|
|
21
|
+
|
|
22
|
+
key = api_key or os.environ.get("ZHIPUAI_API_KEY", "")
|
|
23
|
+
if not key:
|
|
24
|
+
# 尝试从 openclaw 配置读取
|
|
25
|
+
cfg_path = Path.home() / ".openclaw" / "config.yaml"
|
|
26
|
+
if cfg_path.exists():
|
|
27
|
+
import yaml
|
|
28
|
+
try:
|
|
29
|
+
cfg = yaml.safe_load(cfg_path.read_text())
|
|
30
|
+
key = cfg.get("zhipu", {}).get("apiKey", "") or cfg.get("providers", {}).get("zhipu", {}).get("apiKey", "")
|
|
31
|
+
except: pass
|
|
32
|
+
|
|
33
|
+
if not key:
|
|
34
|
+
print("⚠️ 未找到 ZHIPUAI_API_KEY,请设置环境变量或在 .env 中配置")
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
url = "https://open.bigmodel.cn/api/paas/v4/embeddings"
|
|
38
|
+
payload = json.dumps({
|
|
39
|
+
"model": "embedding-3",
|
|
40
|
+
"input": text[:8000], # 截断过长文本
|
|
41
|
+
"dimensions": 1024
|
|
42
|
+
}).encode()
|
|
43
|
+
|
|
44
|
+
req = urllib.request.Request(url, data=payload, headers={
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"Authorization": f"Bearer {key}"
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
51
|
+
data = json.loads(resp.read())
|
|
52
|
+
return data["data"][0]["embedding"]
|
|
53
|
+
except Exception as e:
|
|
54
|
+
print(f"⚠️ Embedding API 错误: {e}")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# ====== 文件扫描 ======
|
|
58
|
+
def scan_wiki_files() -> list:
|
|
59
|
+
"""扫描所有 wiki markdown 文件,返回 (path, content, meta) 列表"""
|
|
60
|
+
files = []
|
|
61
|
+
for md_file in sorted(WIKI_DIR.rglob("*.md")):
|
|
62
|
+
# 跳过隐藏文件和特殊文件
|
|
63
|
+
if any(p.startswith(".") for p in md_file.parts):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
text = md_file.read_text(encoding="utf-8", errors="ignore")
|
|
67
|
+
if len(text.strip()) < 50: # 跳过太短的文件
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# 提取 frontmatter 之后的正文用于 embedding
|
|
71
|
+
body = text
|
|
72
|
+
if text.startswith("---"):
|
|
73
|
+
parts = text.split("---", 2)
|
|
74
|
+
if len(parts) >= 3:
|
|
75
|
+
body = parts[2].strip()
|
|
76
|
+
|
|
77
|
+
# 截取前 2000 字符作为 embedding 内容(标题+摘要+关键内容)
|
|
78
|
+
embed_text = body[:2000]
|
|
79
|
+
|
|
80
|
+
rel_path = str(md_file.relative_to(WIKI_DIR))
|
|
81
|
+
files.append({
|
|
82
|
+
"path": rel_path,
|
|
83
|
+
"full_path": str(md_file),
|
|
84
|
+
"title": md_file.stem,
|
|
85
|
+
"body": body,
|
|
86
|
+
"embed_text": embed_text,
|
|
87
|
+
"size": len(text),
|
|
88
|
+
"category": rel_path.split("/")[0] if "/" in rel_path else "root"
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return files
|
|
92
|
+
|
|
93
|
+
# ====== Build Index ======
|
|
94
|
+
def build_index(force=False):
|
|
95
|
+
"""构建/更新向量索引"""
|
|
96
|
+
print(f"📚 扫描 Wiki 目录: {WIKI_DIR}")
|
|
97
|
+
files = scan_wiki_files()
|
|
98
|
+
print(f"📊 找到 {len(files)} 个页面")
|
|
99
|
+
|
|
100
|
+
# 加载已有缓存
|
|
101
|
+
cache = {}
|
|
102
|
+
if EMBED_CACHE.exists() and not force:
|
|
103
|
+
cache = json.loads(EMBED_CACHE.read_text())
|
|
104
|
+
|
|
105
|
+
index = []
|
|
106
|
+
new_count = 0
|
|
107
|
+
cache_count = 0
|
|
108
|
+
|
|
109
|
+
for i, f in enumerate(files):
|
|
110
|
+
# 用文件路径+大小+修改时间做 hash 判断是否需要重新 embedding
|
|
111
|
+
file_hash = hashlib.md5(f"{f['path']}:{f['size']}".encode()).hexdigest()
|
|
112
|
+
|
|
113
|
+
if file_hash in cache and not force:
|
|
114
|
+
index.append({**f, "embedding": cache[file_hash], "_hash": file_hash})
|
|
115
|
+
cache_count += 1
|
|
116
|
+
else:
|
|
117
|
+
print(f" [{i+1}/{len(files)}] Embedding: {f['path']} ...", end=" ", flush=True)
|
|
118
|
+
emb = get_embedding(f["embed_text"])
|
|
119
|
+
if emb:
|
|
120
|
+
f["embedding"] = emb
|
|
121
|
+
f["_hash"] = file_hash
|
|
122
|
+
index.append(f)
|
|
123
|
+
cache[file_hash] = emb
|
|
124
|
+
new_count += 1
|
|
125
|
+
print("✅")
|
|
126
|
+
else:
|
|
127
|
+
print("❌ 跳过")
|
|
128
|
+
|
|
129
|
+
# 每 10 个保存一次缓存
|
|
130
|
+
if (i + 1) % 20 == 0:
|
|
131
|
+
EMBED_CACHE.write_text(json.dumps(cache))
|
|
132
|
+
|
|
133
|
+
# 保存最终结果
|
|
134
|
+
EMBED_CACHE.write_text(json.dumps(cache))
|
|
135
|
+
INDEX_FILE.write_text(json.dumps(index, ensure_ascii=False, indent=2))
|
|
136
|
+
|
|
137
|
+
print(f"\n✅ 索引构建完成!")
|
|
138
|
+
print(f" 新增: {new_count} | 缓存: {cache_count} | 总计: {len(index)}")
|
|
139
|
+
print(f" 索引文件: {INDEX_FILE} ({INDEX_FILE.stat().st_size / 1024:.1f} KB)")
|
|
140
|
+
print(f" 缓存文件: {EMBED_CACHE} ({EMBED_CACHE.stat().st_size / 1024:.1f} KB)")
|
|
141
|
+
|
|
142
|
+
# ====== Query ======
|
|
143
|
+
def query(text: str, top_k: int = 5, category_filter: str = None) -> list:
|
|
144
|
+
"""语义查询,返回最相关的页面"""
|
|
145
|
+
if not INDEX_FILE.exists():
|
|
146
|
+
print("❌ 索引不存在,请先运行: python3 vector_store.py build")
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
index = json.loads(INDEX_FILE.read_text())
|
|
150
|
+
if not index:
|
|
151
|
+
print("❌ 索引为空")
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
print(f"🔍 查询: \"{text}\"")
|
|
155
|
+
query_emb = get_embedding(text)
|
|
156
|
+
if not query_emb:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
# 余弦相似度
|
|
160
|
+
def cosine_similarity(a, b):
|
|
161
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
162
|
+
norm_a = sum(x * x for x in a) ** 0.5
|
|
163
|
+
norm_b = sum(x * x for x in b) ** 0.5
|
|
164
|
+
if norm_a == 0 or norm_b == 0: return 0
|
|
165
|
+
return dot / (norm_a * norm_b)
|
|
166
|
+
|
|
167
|
+
results = []
|
|
168
|
+
for item in index:
|
|
169
|
+
if category_filter and item.get("category") != category_filter:
|
|
170
|
+
continue
|
|
171
|
+
score = cosine_similarity(query_emb, item["embedding"])
|
|
172
|
+
results.append({**item, "score": round(score, 4)})
|
|
173
|
+
|
|
174
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
175
|
+
top = results[:top_k]
|
|
176
|
+
|
|
177
|
+
print(f"\n📋 Top {len(top)} 结果:\n")
|
|
178
|
+
for r in top:
|
|
179
|
+
cat_emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(r.get("category"), "📄")
|
|
180
|
+
print(f" {cat_emoji} [{r['score']:.3f}] {r['path']}")
|
|
181
|
+
print(f" ({r['size']} chars | {r['category']})")
|
|
182
|
+
# 显示匹配到的关键词上下文
|
|
183
|
+
body_preview = r.get("body", "")[:200].replace("\n", " ")
|
|
184
|
+
print(f" 预览: {body_preview}...")
|
|
185
|
+
print()
|
|
186
|
+
|
|
187
|
+
return top
|
|
188
|
+
|
|
189
|
+
# ====== Stats ======
|
|
190
|
+
def show_stats():
|
|
191
|
+
"""显示索引统计"""
|
|
192
|
+
if not INDEX_FILE.exists():
|
|
193
|
+
print("❌ 索引不存在"); return
|
|
194
|
+
|
|
195
|
+
index = json.loads(INDEX_FILE.read_text())
|
|
196
|
+
categories = {}
|
|
197
|
+
for item in index:
|
|
198
|
+
c = item.get("category", "root")
|
|
199
|
+
categories[c] = categories.get(c, 0) + 1
|
|
200
|
+
|
|
201
|
+
print(f"📊 Vector Store 统计:")
|
|
202
|
+
print(f" 总页面数: {len(index)}")
|
|
203
|
+
print(f" 索引大小: {INDEX_FILE.stat().st_size / 1024:.1f} KB")
|
|
204
|
+
print(f" 缓存大小: {EMBED_CACHE.stat().st_size / 1024:.1f} KB" if EMBED_CACHE.exists() else "")
|
|
205
|
+
print(f"\n 按分类:")
|
|
206
|
+
for c, cnt in sorted(categories.items(), key=lambda x: -x[1]):
|
|
207
|
+
emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(c, "📁")
|
|
208
|
+
print(f" {emoji} {c}: {cnt}")
|
|
209
|
+
|
|
210
|
+
# ====== Main ======
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else "stats"
|
|
213
|
+
|
|
214
|
+
if cmd == "build":
|
|
215
|
+
build_index("--force" in sys.argv)
|
|
216
|
+
elif cmd == "query":
|
|
217
|
+
q = " ".join(sys.argv[2:])
|
|
218
|
+
if not q:
|
|
219
|
+
print("用法: python3 vector_store.py query \"搜索内容\"")
|
|
220
|
+
else:
|
|
221
|
+
query(q)
|
|
222
|
+
elif cmd == "stats":
|
|
223
|
+
show_stats()
|
|
224
|
+
else:
|
|
225
|
+
print(__doc__)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# jerry-wiki wechat article sync
|
|
3
|
+
# Searches for WeChat articles via Brave Search and ingests them
|
|
4
|
+
# Usage: bash scripts/wechat_sync.sh [--dry-run]
|
|
5
|
+
#
|
|
6
|
+
# Config: scripts/.wechat-accounts.json (search queries + accounts)
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
WIKI_ROOT="${KNOWFLOW_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
|
11
|
+
RAW_BASE="${KNOWFLOW_RAW_DIR:-$WIKI_ROOT/raw}"
|
|
12
|
+
RAW_DIR="$RAW_BASE/wechat"
|
|
13
|
+
STATE_FILE="$WIKI_ROOT/.wechat-state.json"
|
|
14
|
+
TIMESTAMP=$(date +%Y-%m-%d-%H%M)
|
|
15
|
+
ACCOUNTS_FILE="$SCRIPT_DIR/.wechat-accounts.json"
|
|
16
|
+
|
|
17
|
+
mkdir -p "$RAW_DIR"
|
|
18
|
+
|
|
19
|
+
# ── Load config ────────────────────────────────────────
|
|
20
|
+
if [ ! -f "$ACCOUNTS_FILE" ]; then
|
|
21
|
+
cat > "$ACCOUNTS_FILE" << 'EOF'
|
|
22
|
+
{
|
|
23
|
+
"accounts": [
|
|
24
|
+
{"name": "宝玉", "keywords": ["宝玉", "dotey", "Claude Code"]},
|
|
25
|
+
{"name": "科技爱好者", "keywords": ["AI 工具", "AI 编程", "开源"]}
|
|
26
|
+
],
|
|
27
|
+
"searchQueries": [
|
|
28
|
+
"AI编程工具 site:mp.weixin.qq.com",
|
|
29
|
+
"Claude Code 教程 site:mp.weixin.qq.com",
|
|
30
|
+
"AI Agent 开发 site:mp.weixin.qq.com",
|
|
31
|
+
"OpenClaw 使用 site:mp.weixin.qq.com"
|
|
32
|
+
],
|
|
33
|
+
"maxResults": 10,
|
|
34
|
+
"maxAgeDays": 7
|
|
35
|
+
}
|
|
36
|
+
EOF
|
|
37
|
+
echo "📝 Created default $ACCOUNTS_FILE (edit to customize)"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
CONFIG=$(cat "$ACCOUNTS_FILE")
|
|
41
|
+
|
|
42
|
+
# Read queries into temp file (handle spaces in queries)
|
|
43
|
+
TMP_QUERIES=$(mktemp)
|
|
44
|
+
echo "$CONFIG" | python3 -c "
|
|
45
|
+
import json, sys
|
|
46
|
+
data = json.load(sys.stdin)
|
|
47
|
+
for q in data.get('searchQueries', []):
|
|
48
|
+
print(q)
|
|
49
|
+
" > "$TMP_QUERIES" 2>/dev/null || true
|
|
50
|
+
|
|
51
|
+
MAX_RESULTS=$(echo "$CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin).get('maxResults',5))" 2>/dev/null || echo 5)
|
|
52
|
+
|
|
53
|
+
if [ ! -s "$TMP_QUERIES" ]; then
|
|
54
|
+
rm -f "$TMP_QUERIES"
|
|
55
|
+
echo "❌ No search queries configured in $ACCOUNTS_FILE"
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
DRY_RUN=false
|
|
60
|
+
case "${1:-}" in --dry-run) DRY_RUN=true ;; esac
|
|
61
|
+
|
|
62
|
+
# ── State tracking ─────────────────────────────────────
|
|
63
|
+
SEEN_URLS=$(python3 -c "
|
|
64
|
+
import json
|
|
65
|
+
state = {}
|
|
66
|
+
try:
|
|
67
|
+
with open('$STATE_FILE') as f:
|
|
68
|
+
state = json.load(f)
|
|
69
|
+
except: pass
|
|
70
|
+
for url in state.get('seenUrls', []):
|
|
71
|
+
print(url)
|
|
72
|
+
" 2>/dev/null || true)
|
|
73
|
+
|
|
74
|
+
_TEMP_FILES=("$TMP_QUERIES")
|
|
75
|
+
cleanup() { rm -f "${_TEMP_FILES[@]:-}" 2>/dev/null || true; }
|
|
76
|
+
trap cleanup EXIT INT TERM
|
|
77
|
+
|
|
78
|
+
# ── Search & Fetch ──────────────────────────────────────
|
|
79
|
+
echo "📱 WeChat Article Sync — $TIMESTAMP"
|
|
80
|
+
echo ""
|
|
81
|
+
|
|
82
|
+
NEW_COUNT=0
|
|
83
|
+
TMPJSON=$(mktemp)
|
|
84
|
+
_TEMP_FILES+=("$TMPJSON")
|
|
85
|
+
|
|
86
|
+
while IFS= read -r query; do
|
|
87
|
+
[ -z "$query" ] && continue
|
|
88
|
+
echo "🔍 Searching: $query"
|
|
89
|
+
|
|
90
|
+
# Try brave-search first (reliable), fallback to exa
|
|
91
|
+
OK=false
|
|
92
|
+
if command -v mcporter &>/dev/null; then
|
|
93
|
+
mcporter call 'brave-search.brave_web_search' query="$query" count="$MAX_RESULTS" > "$TMPJSON" 2>/dev/null && OK=true
|
|
94
|
+
if [ "$OK" = false ]; then
|
|
95
|
+
mcporter call 'exa.web_search_exa' query="$query" numResults="$MAX_RESULTS" includeDomains='["mp.weixin.qq.com"]' > "$TMPJSON" 2>/dev/null && OK=true
|
|
96
|
+
fi
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
if [ "$OK" = false ]; then
|
|
100
|
+
echo " ⚠️ All search providers failed, skipping query"
|
|
101
|
+
continue
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
# Parse results and download new articles
|
|
105
|
+
# brave-search outputs plain text (Title/Description/URL blocks), exa outputs JSON
|
|
106
|
+
python3 - "$TMPJSON" "$RAW_DIR" "$TIMESTAMP" "$STATE_FILE" << 'PYEOF'
|
|
107
|
+
import json, sys, os, subprocess, re
|
|
108
|
+
from datetime import datetime
|
|
109
|
+
|
|
110
|
+
tmpjson, raw_dir, timestamp, state_file = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
|
111
|
+
|
|
112
|
+
with open(tmpjson) as f:
|
|
113
|
+
raw = f.read()
|
|
114
|
+
|
|
115
|
+
results = []
|
|
116
|
+
|
|
117
|
+
# Try JSON first (exa format)
|
|
118
|
+
try:
|
|
119
|
+
start = raw.index('{')
|
|
120
|
+
end = raw.rindex('}') + 1
|
|
121
|
+
data = json.loads(raw[start:end])
|
|
122
|
+
results = data.get('results', []) if isinstance(data, dict) else []
|
|
123
|
+
if not results and isinstance(data, list):
|
|
124
|
+
results = data
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
# Fallback: parse plain text format (brave-search)
|
|
129
|
+
if not results:
|
|
130
|
+
# Split by "Title: " pattern to find result blocks
|
|
131
|
+
blocks = re.split(r'(?=^Title:\s)', raw, flags=re.MULTILINE)
|
|
132
|
+
for block in blocks[1:]: # Skip header if any
|
|
133
|
+
title_m = re.search(r'^Title:\s*(.+)', block, re.MULTILINE)
|
|
134
|
+
url_m = re.search(r'^URL:\s*(https?://\S+)', block, re.MULTILINE)
|
|
135
|
+
desc_m = re.search(r'^Description:\s*(.+?)(?=^URL:|^Title:|$)', block, re.MULTILINE | re.DOTALL)
|
|
136
|
+
if url_m:
|
|
137
|
+
results.append({
|
|
138
|
+
'url': url_m.group(1).strip(),
|
|
139
|
+
'title': (title_m.group(1).strip() if title_m else 'unknown'),
|
|
140
|
+
'description': (desc_m.group(1).strip()[:500] if desc_m else ''),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
if not results:
|
|
144
|
+
print(" ℹ️ No results")
|
|
145
|
+
sys.exit(0)
|
|
146
|
+
|
|
147
|
+
state = {"seenUrls": [], "lastSyncAt": ""}
|
|
148
|
+
if os.path.exists(state_file):
|
|
149
|
+
try:
|
|
150
|
+
with open(state_file) as f:
|
|
151
|
+
state = json.load(f)
|
|
152
|
+
except: pass
|
|
153
|
+
seen = set(state.get("seenUrls", []))
|
|
154
|
+
|
|
155
|
+
new_count = 0
|
|
156
|
+
for r in results[:10]:
|
|
157
|
+
url = r.get("url", "")
|
|
158
|
+
if not url or url in seen or "mp.weixin.qq.com" not in url:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
title = str(r.get("title", "unknown"))
|
|
162
|
+
published = str(r.get("publishedDate", ""))[:10]
|
|
163
|
+
|
|
164
|
+
safe_title = re.sub(r'[/\\|:*?<>]', '-', title)[:60]
|
|
165
|
+
filename = f"{timestamp}-{published}-{safe_title}.md"
|
|
166
|
+
filepath = os.path.join(str(raw_dir), filename)
|
|
167
|
+
|
|
168
|
+
if os.path.exists(filepath):
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
result = subprocess.run(
|
|
173
|
+
["curl", "-sL", "--max-time", "20", f"https://r.jina.ai/{url}"],
|
|
174
|
+
capture_output=True, text=True, timeout=25
|
|
175
|
+
)
|
|
176
|
+
content = result.stdout.strip()
|
|
177
|
+
if len(content) < 100:
|
|
178
|
+
content = f"# {title}\n\n> Source: {url}\n> Published: {published}\n\n{r.get('text', '')[:2000]}"
|
|
179
|
+
|
|
180
|
+
with open(filepath, 'w') as f:
|
|
181
|
+
f.write(content)
|
|
182
|
+
seen.add(url)
|
|
183
|
+
new_count += 1
|
|
184
|
+
print(f" + {filename}")
|
|
185
|
+
except Exception as e:
|
|
186
|
+
print(f" ✗ Failed: {title} ({e})")
|
|
187
|
+
|
|
188
|
+
state["seenUrls"] = list(seen)[-500:]
|
|
189
|
+
state["lastSyncAt"] = datetime.now().isoformat()
|
|
190
|
+
with open(state_file, 'w') as f:
|
|
191
|
+
json.dump(state, f, indent=2, ensure_ascii=False)
|
|
192
|
+
|
|
193
|
+
print(f"\n 📊 {new_count} new articles saved")
|
|
194
|
+
PYEOF
|
|
195
|
+
|
|
196
|
+
done < "$TMP_QUERIES"
|
|
197
|
+
|
|
198
|
+
echo ""
|
|
199
|
+
echo "✅ WeChat sync complete. Run pipeline.sh to ingest → build."
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# knowflow wiki-auto-fix — 自动修复 health check 发现的问题
|
|
3
|
+
# Usage: bash scripts/wiki-auto-fix.sh [--wiki-dir <path>] [--dry-run]
|
|
4
|
+
#
|
|
5
|
+
# 在 health check 之前运行,自动:
|
|
6
|
+
# 1. 清理空链接 [[entities/,]] [[concepts/,]]
|
|
7
|
+
# 2. 创建缺失的 entity/concept 文件
|
|
8
|
+
# 3. 补充过小的文件(<100B)
|
|
9
|
+
# 4. 关联孤儿页(添加引用)
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
# ── Config ──────────────────────────────────────────────
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
15
|
+
WIKI_ROOT="${KNOWFLOW_ROOT:-${WIKI_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}}"
|
|
16
|
+
WIKI_DIR="${KNOWFLOW_WIKI_DIR:-$WIKI_ROOT/wiki}"
|
|
17
|
+
DRY_RUN=false
|
|
18
|
+
MIN_SIZE=100
|
|
19
|
+
|
|
20
|
+
while [ $# -gt 0 ]; do
|
|
21
|
+
case "$1" in
|
|
22
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
23
|
+
--wiki-root) WIKI_ROOT="$2"; WIKI_DIR="$WIKI_ROOT/wiki"; shift 2 ;;
|
|
24
|
+
--wiki-dir) WIKI_DIR="$2"; shift 2 ;;
|
|
25
|
+
--min-size) MIN_SIZE="$2"; shift 2 ;;
|
|
26
|
+
*) echo "Unknown: $1" >&2; exit 1 ;;
|
|
27
|
+
esac
|
|
28
|
+
done
|
|
29
|
+
|
|
30
|
+
# Portable in-place sed: BSD (macOS) needs -i '' with the backup suffix arg,
|
|
31
|
+
# GNU sed takes the script directly after -i.
|
|
32
|
+
sed_i() {
|
|
33
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
34
|
+
sed -i '' "$@"
|
|
35
|
+
else
|
|
36
|
+
sed -i "$@"
|
|
37
|
+
fi
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
FIXED_LINKS=0
|
|
41
|
+
CREATED_FILES=0
|
|
42
|
+
PADDED_FILES=0
|
|
43
|
+
|
|
44
|
+
run_cmd() {
|
|
45
|
+
if $DRY_RUN; then
|
|
46
|
+
echo "[DRY] $*"
|
|
47
|
+
else
|
|
48
|
+
"$@"
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
echo "🔧 Wiki Auto-Fix"
|
|
53
|
+
echo "================"
|
|
54
|
+
|
|
55
|
+
# ════════════════════════════════════════════════
|
|
56
|
+
# Fix 1: 清理空链接 [[entities/,]] [[concepts/,]]
|
|
57
|
+
# ════════════════════════════════════════════════
|
|
58
|
+
echo ""
|
|
59
|
+
echo "[1/4] 清理空链接..."
|
|
60
|
+
|
|
61
|
+
while IFS= read -r -d '' file; do
|
|
62
|
+
if grep -q '\[\[entities\/,\]\]\|\[\[concepts\/,\]\]' "$file" 2>/dev/null; then
|
|
63
|
+
local_count=$(grep -c '\[\[entities\/,\]\]\|\[\[concepts\/,\]\]' "$file" 2>/dev/null || echo 0)
|
|
64
|
+
run_cmd sed_i '/\[\[entities\/,\]\]/d' "$file"
|
|
65
|
+
run_cmd sed_i '/\[\[concepts\/,\]\]/d' "$file"
|
|
66
|
+
FIXED_LINKS=$((FIXED_LINKS + local_count))
|
|
67
|
+
rel="${file#$WIKI_DIR/}"
|
|
68
|
+
echo " ✏️ $rel (清理 $local_count 个空链接)"
|
|
69
|
+
fi
|
|
70
|
+
done < <(find "$WIKI_DIR" -name '*.md' -not -path '*/.understand-anything/*' -print0)
|
|
71
|
+
echo " 共清理 $FIXED_LINKS 个空链接"
|
|
72
|
+
|
|
73
|
+
# ════════════════════════════════════════════════
|
|
74
|
+
# Fix 2: 创建缺失的 entity/concept 文件
|
|
75
|
+
# ════════════════════════════════════════════════
|
|
76
|
+
echo ""
|
|
77
|
+
echo "[2/4] 创建缺失实体/概念文件..."
|
|
78
|
+
|
|
79
|
+
LINKS_TMP="$(mktemp)"
|
|
80
|
+
while IFS= read -r -d '' file; do
|
|
81
|
+
grep -o '\[\[entities\/[^]]*\]\]\|\[\[concepts\/[^]]*\]\]' "$file" 2>/dev/null \
|
|
82
|
+
| sed 's/\[\[//;s/\]\]//' \
|
|
83
|
+
| sed 's/|.*//' \
|
|
84
|
+
| grep -v '^[[:space:]]*$' \
|
|
85
|
+
| awk '!seen[$0]++' >> "$LINKS_TMP" || true
|
|
86
|
+
done < <(find "$WIKI_DIR" -name '*.md' -not -path '*/.understand-anything/*' -print0)
|
|
87
|
+
|
|
88
|
+
while IFS= read -r link; do
|
|
89
|
+
[ -z "$link" ] && continue
|
|
90
|
+
target_file="$WIKI_DIR/${link}.md"
|
|
91
|
+
if [ ! -f "$target_file" ]; then
|
|
92
|
+
name=$(basename "$link")
|
|
93
|
+
display_name=$(echo "$name" | sed 's/-/ /g')
|
|
94
|
+
|
|
95
|
+
case "${link%%/*}" in
|
|
96
|
+
entities) type_label="实体" ;;
|
|
97
|
+
concepts) type_label="概念" ;;
|
|
98
|
+
topics) type_label="主题" ;;
|
|
99
|
+
*) type_label="待分类" ;;
|
|
100
|
+
esac
|
|
101
|
+
|
|
102
|
+
run_cmd mkdir -p "$(dirname "$target_file")"
|
|
103
|
+
# NOTE: redirections run before `run_cmd` is called, so we must not route the
|
|
104
|
+
# heredoc through run_cmd in dry-run mode — the `>` would truncate/create the
|
|
105
|
+
# file even when the command itself is skipped. Render to a temp buffer and
|
|
106
|
+
# write once outside dry-run.
|
|
107
|
+
content=$(printf '# %s\n\n## 类型\n%s\n\n## 描述\n(由 wiki-auto-fix 自动创建,待补充详细信息)\n' "$display_name" "$type_label")
|
|
108
|
+
if $DRY_RUN; then
|
|
109
|
+
echo "[DRY] create $target_file"
|
|
110
|
+
else
|
|
111
|
+
printf '%s' "$content" > "$target_file"
|
|
112
|
+
fi
|
|
113
|
+
CREATED_FILES=$((CREATED_FILES + 1))
|
|
114
|
+
echo " ➕ ${link}.md (${type_label})"
|
|
115
|
+
fi
|
|
116
|
+
done < "$LINKS_TMP"
|
|
117
|
+
rm -f "$LINKS_TMP"
|
|
118
|
+
echo " 共创建 $CREATED_FILES 个缺失文件"
|
|
119
|
+
|
|
120
|
+
# ════════════════════════════════════════════════
|
|
121
|
+
# Fix 3: 补充过小文件
|
|
122
|
+
# ════════════════════════════════════════════════
|
|
123
|
+
echo ""
|
|
124
|
+
echo "[3/4] 补充过小文件 (<${MIN_SIZE}B)..."
|
|
125
|
+
|
|
126
|
+
while IFS= read -r -d '' file; do
|
|
127
|
+
size=$(wc -c < "$file" | tr -d ' ')
|
|
128
|
+
rel="${file#$WIKI_DIR/}"
|
|
129
|
+
name_base=$(basename "$rel" .md)
|
|
130
|
+
|
|
131
|
+
case "$rel" in
|
|
132
|
+
entities/*) suffix="\n\n## 备注\n此页面由 wiki-auto-fix 自动扩充。原始内容仅 ${size} 字节。" ;;
|
|
133
|
+
concepts/*) suffix="\n\n## 延伸阅读\n此概念页面由 wiki-auto-fix 自动扩充。原始内容仅 ${size} 字节。" ;;
|
|
134
|
+
topics/*) suffix="\n\n## 子主题\n此主题页面由 wiki-auto-fix 自动扩充。原始内容仅 ${size} 字节。" ;;
|
|
135
|
+
*) suffix="\n\n---\n*此文件由 wiki-auto-fix 标记为过小(原 ${size}B)*" ;;
|
|
136
|
+
esac
|
|
137
|
+
|
|
138
|
+
# Same dry-run caveat as Fix 2: route the write explicitly, never through a
|
|
139
|
+
# redirection that the shell would apply before run_cmd decides to skip.
|
|
140
|
+
if $DRY_RUN; then
|
|
141
|
+
echo "[DRY] pad $rel (+$(printf "$suffix" | wc -c | tr -d ' ') bytes)"
|
|
142
|
+
else
|
|
143
|
+
printf '%b%s\n' "$(cat "$file")" "$suffix" > "$file"
|
|
144
|
+
fi
|
|
145
|
+
PADDED_FILES=$((PADDED_FILES + 1))
|
|
146
|
+
echo " 📝 $rel (${size}B → 已补充)"
|
|
147
|
+
done < <(find "$WIKI_DIR" -name '*.md' -not -path '*/.understand-anything/*' -size +0 -size -${MIN_SIZE}c -print0)
|
|
148
|
+
|
|
149
|
+
echo " 共补充 $PADDED_FILES 个文件"
|
|
150
|
+
|
|
151
|
+
# ════════════════════════════════════════════════
|
|
152
|
+
# Fix 4: 关联孤儿页(用 Python 避免 bash [[ 冲突)
|
|
153
|
+
# ════════════════════════════════════════════════
|
|
154
|
+
echo ""
|
|
155
|
+
echo "[4/4] 处理孤儿页..."
|
|
156
|
+
|
|
157
|
+
python3 - "$WIKI_DIR" "$DRY_RUN" << 'PYEOF'
|
|
158
|
+
import os, sys, re
|
|
159
|
+
|
|
160
|
+
wiki_dir = sys.argv[1]
|
|
161
|
+
dry_run = sys.argv[2].lower() == "true"
|
|
162
|
+
index_path = os.path.join(wiki_dir, "index.md")
|
|
163
|
+
skip_prefixes = ("index.md", "overview.md", "topics.md", "log.md",
|
|
164
|
+
".vector", ".embed", "comparisons/", "sources/")
|
|
165
|
+
|
|
166
|
+
referenced = set()
|
|
167
|
+
for root, dirs, files in os.walk(wiki_dir):
|
|
168
|
+
if ".understand-anything" in root:
|
|
169
|
+
continue
|
|
170
|
+
for f in files:
|
|
171
|
+
if not f.endswith(".md"):
|
|
172
|
+
continue
|
|
173
|
+
fpath = os.path.join(root, f)
|
|
174
|
+
try:
|
|
175
|
+
with open(fpath, "r", errors="replace") as fh:
|
|
176
|
+
for line in fh:
|
|
177
|
+
for m in re.finditer(r'\[\[([^]|]+?)(?:\|[^]]+)?\]\]', line):
|
|
178
|
+
target = m.group(1).strip()
|
|
179
|
+
if not target:
|
|
180
|
+
continue
|
|
181
|
+
if target.startswith(("entities/", "concepts/", "topics/")):
|
|
182
|
+
tpath = os.path.join(wiki_dir, target + ".md")
|
|
183
|
+
elif target.startswith("/"):
|
|
184
|
+
tpath = os.path.join(wiki_dir, target.lstrip("/") + ".md")
|
|
185
|
+
else:
|
|
186
|
+
continue
|
|
187
|
+
referenced.add(os.path.normpath(tpath))
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
orphan_count = 0
|
|
192
|
+
fixed_count = 0
|
|
193
|
+
for root, dirs, files in os.walk(wiki_dir):
|
|
194
|
+
if ".understand-anything" in root:
|
|
195
|
+
continue
|
|
196
|
+
for f in files:
|
|
197
|
+
if not f.endswith(".md"):
|
|
198
|
+
continue
|
|
199
|
+
rel = os.path.relpath(os.path.join(root, f), wiki_dir)
|
|
200
|
+
if rel.startswith(skip_prefixes) or rel == "index.md":
|
|
201
|
+
continue
|
|
202
|
+
fpath = os.path.join(root, f)
|
|
203
|
+
if fpath not in referenced and os.path.isfile(fpath):
|
|
204
|
+
orphan_count += 1
|
|
205
|
+
name_base = os.path.splitext(f)[0]
|
|
206
|
+
if os.path.exists(index_path):
|
|
207
|
+
try:
|
|
208
|
+
with open(index_path, "r", errors="replace") as idx:
|
|
209
|
+
idx_content = idx.read()
|
|
210
|
+
if f"[[{name_base}]]" not in idx_content:
|
|
211
|
+
if not dry_run:
|
|
212
|
+
with open(index_path, "a") as idx:
|
|
213
|
+
idx.write(f"- [[{name_base}]] (orphan auto-linked)\n")
|
|
214
|
+
fixed_count += 1
|
|
215
|
+
print(f" 🔗 {rel} -> index.md")
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print(f" WARN: {e}")
|
|
218
|
+
|
|
219
|
+
print(f" 发现 {orphan_count} 个孤儿页,已关联 {fixed_count} 个")
|
|
220
|
+
PYEOF
|
|
221
|
+
|
|
222
|
+
# ── Summary ────────────────────────────────────────────
|
|
223
|
+
TOTAL_FIXED=$((FIXED_LINKS + CREATED_FILES + PADDED_FILES))
|
|
224
|
+
|
|
225
|
+
echo ""
|
|
226
|
+
echo "📊 Auto-Fix Summary"
|
|
227
|
+
echo "-------------------"
|
|
228
|
+
echo " 空链接清理: $FIXED_LINKS"
|
|
229
|
+
echo " 缺失文件创建: $CREATED_FILES"
|
|
230
|
+
echo " 过小文件补充: $PADDED_FILES"
|
|
231
|
+
echo " 总计修复: $TOTAL_FIXED"
|
|
232
|
+
|
|
233
|
+
if $DRY_RUN; then
|
|
234
|
+
echo ""
|
|
235
|
+
echo "(DRY RUN — 未实际修改)"
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
exit 0
|