weflow-cli 1.5.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/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- package/src/utils/talkerUtils.ts +84 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
后处理:广告清洗 → 兴趣主题深度摘要 → 分文件夹 → 重建README。
|
|
4
|
+
|
|
5
|
+
前置: 已运行 biz_daily.py,md 文件中已含【主题】标签。
|
|
6
|
+
|
|
7
|
+
用法: python scripts/classify_daily.py [date_dir] --api-key <key> [--interest AI]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys, os, json, re, time, urllib.request, shutil
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# 公共工具
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
from _utils import (
|
|
17
|
+
call_deepseek, parse_frontmatter, write_with_frontmatter,
|
|
18
|
+
DEFAULT_USER_PROFILE, generate_action_suggestion, load_config
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
OUTPUT_ROOT = 'output/biz-daily'
|
|
22
|
+
TOPICS = ['AI', '学术', '新闻', '文学', '投资']
|
|
23
|
+
|
|
24
|
+
AD_PATTERNS = [
|
|
25
|
+
re.compile(r'在小说阅读器读本章\s*'),
|
|
26
|
+
re.compile(r'在小说阅读器中沉浸阅读\s*'),
|
|
27
|
+
re.compile(r'去阅读\s*'),
|
|
28
|
+
re.compile(r'Scan to Follow\s*'),
|
|
29
|
+
re.compile(r'轻触阅读原文\s*'),
|
|
30
|
+
re.compile(r'预览时标签不可点\s*'),
|
|
31
|
+
re.compile(r'继续滑动看下一个\s*'),
|
|
32
|
+
re.compile(r'\[.*?\]\(javascript:void\(0\);\)'),
|
|
33
|
+
re.compile(r'\n{4,}'),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
INTEREST_PROMPT = """对以下AI领域文章生成深度解析:
|
|
37
|
+
|
|
38
|
+
标题:{title}
|
|
39
|
+
来源:{source}
|
|
40
|
+
|
|
41
|
+
正文:
|
|
42
|
+
{content}
|
|
43
|
+
|
|
44
|
+
请用markdown返回:
|
|
45
|
+
### 核心观点
|
|
46
|
+
(1-2句)
|
|
47
|
+
|
|
48
|
+
### 关键细节
|
|
49
|
+
- 点1
|
|
50
|
+
- 点2
|
|
51
|
+
- 点3
|
|
52
|
+
|
|
53
|
+
### 启示
|
|
54
|
+
(1句话)"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
BATCH_ACTION_PROMPT = """你是读者的科研助手。基于以下文章列表,为读者筛选并生成可落地的行动建议。
|
|
58
|
+
|
|
59
|
+
【读者定位】
|
|
60
|
+
{profile}
|
|
61
|
+
|
|
62
|
+
【文章列表】
|
|
63
|
+
{articles}
|
|
64
|
+
|
|
65
|
+
请完成以下任务:
|
|
66
|
+
|
|
67
|
+
1. **筛选**:只保留对读者有实际落地价值的文章(排除纯新闻、娱乐、广告、无关内容)
|
|
68
|
+
2. **评估**:对每篇保留的文章,判断相关度(高/中)并给出理由
|
|
69
|
+
3. **生成建议**:为每篇保留的文章生成三级行动建议:
|
|
70
|
+
- **立即可做**:今天就能执行的具体动作
|
|
71
|
+
- **本周计划**:本周可以推进的中期动作
|
|
72
|
+
- **长期关注**:值得持续跟踪的方向(仅高相关度时输出)
|
|
73
|
+
|
|
74
|
+
输出格式要求:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
## 📋 今日行动清单(快速浏览)
|
|
78
|
+
- [ ] 动作1(来自:文章标题)
|
|
79
|
+
- [ ] 动作2(来自:文章标题)
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 详细建议
|
|
85
|
+
|
|
86
|
+
### 🔴 高相关度
|
|
87
|
+
|
|
88
|
+
#### 1. 《文章标题》
|
|
89
|
+
> 来源:公众号名 | 主题:AI/学术
|
|
90
|
+
|
|
91
|
+
**相关度**:高 — 一句话解释原因
|
|
92
|
+
|
|
93
|
+
**行动建议**
|
|
94
|
+
- **立即可做**:xxx
|
|
95
|
+
- **本周计划**:xxx
|
|
96
|
+
- **长期关注**:xxx
|
|
97
|
+
|
|
98
|
+
### 🟡 中相关度
|
|
99
|
+
|
|
100
|
+
#### 1. 《文章标题》
|
|
101
|
+
...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
如果筛选后没有值得建议的文章,直接输出:「今日文章暂无直接可落地的行动建议,建议信息性阅读即可。」"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_batch_action_suggestions(articles_data: list[dict], api_key: str, profile: str = '') -> str:
|
|
108
|
+
"""批量生成行动建议。
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
articles_data: 文章数据列表,每项包含 title, source, topic, summary, content
|
|
112
|
+
api_key: DeepSeek API key
|
|
113
|
+
profile: 用户定位描述
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
格式化的行动建议 markdown 文本
|
|
117
|
+
"""
|
|
118
|
+
if not profile:
|
|
119
|
+
profile = DEFAULT_USER_PROFILE
|
|
120
|
+
|
|
121
|
+
# 构建文章列表文本
|
|
122
|
+
articles_text = []
|
|
123
|
+
for i, a in enumerate(articles_data, 1):
|
|
124
|
+
articles_text.append(
|
|
125
|
+
f"[{i}] 《{a['title']}》\n"
|
|
126
|
+
f" 来源:{a['source']} | 主题:{a['topic']} | 相关度:{a.get('relevance', '中')}\n"
|
|
127
|
+
f" 摘要:{a['summary'][:200]}...\n"
|
|
128
|
+
f" 正文节选:{a['content'][:500]}...\n"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
prompt = BATCH_ACTION_PROMPT.format(
|
|
132
|
+
profile=profile,
|
|
133
|
+
articles='\n'.join(articles_text)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return call_deepseek(prompt, api_key, max_tokens=4000, timeout=120)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def clean_ads(text: str) -> str:
|
|
140
|
+
for pat in AD_PATTERNS:
|
|
141
|
+
text = pat.sub('', text)
|
|
142
|
+
text = re.sub(r'\n来源:[^\n]+\n编辑:[^\n]+\n校对:[^\n]+\n校审:[^\n]+', '', text)
|
|
143
|
+
text = re.sub(r'\n>/ [^\n]+', '', text)
|
|
144
|
+
text = re.sub(r'\n{4,}', '\n\n\n', text)
|
|
145
|
+
return text.strip()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def extract_topic_from_file(content: str) -> str:
|
|
149
|
+
"""Extract topic from YAML frontmatter, fallback to text parsing."""
|
|
150
|
+
fm, _ = parse_frontmatter(content)
|
|
151
|
+
if 'topic' in fm and fm['topic'] in TOPICS:
|
|
152
|
+
return fm['topic']
|
|
153
|
+
# Fallback: legacy text-based parsing
|
|
154
|
+
m = re.search(r'> 主题:(\S+)', content)
|
|
155
|
+
if m: return m.group(1)
|
|
156
|
+
return '学术'
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def extract_tags_from_file(content: str) -> list[str]:
|
|
160
|
+
"""Extract tags from YAML frontmatter."""
|
|
161
|
+
fm, _ = parse_frontmatter(content)
|
|
162
|
+
return fm.get('tags', [])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main():
|
|
166
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
167
|
+
import argparse
|
|
168
|
+
parser = argparse.ArgumentParser()
|
|
169
|
+
parser.add_argument('date_dir', nargs='?', help='日期目录')
|
|
170
|
+
parser.add_argument('--api-key', help='DeepSeek API key')
|
|
171
|
+
parser.add_argument('--interest', default='AI', help='兴趣主题,默认 AI')
|
|
172
|
+
parser.add_argument('--profile', default='', help='用户定位描述(默认使用环境科学研究生画像)')
|
|
173
|
+
parser.add_argument('--skip-action', action='store_true', help='跳过行动建议生成')
|
|
174
|
+
args = parser.parse_args()
|
|
175
|
+
|
|
176
|
+
config = load_config()
|
|
177
|
+
api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '') or config.get('deepseekApiKey', '')
|
|
178
|
+
if not api_key:
|
|
179
|
+
print('[ERROR] 需要 DeepSeek API key。请通过 --api-key、环境变量 DEEPSEEK_API_KEY 或 ~/.weflow-cli/config.json 中的 deepseekApiKey 提供')
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
|
|
182
|
+
user_profile = args.profile if args.profile else DEFAULT_USER_PROFILE
|
|
183
|
+
|
|
184
|
+
if args.date_dir:
|
|
185
|
+
base = Path(OUTPUT_ROOT) / args.date_dir
|
|
186
|
+
else:
|
|
187
|
+
dirs = sorted(Path(OUTPUT_ROOT).glob('202*'), reverse=True)
|
|
188
|
+
if not dirs: print('[ERROR] 未找到日报目录'); sys.exit(1)
|
|
189
|
+
base = dirs[0]
|
|
190
|
+
|
|
191
|
+
md_files = sorted([f for f in base.rglob('*.md') if f.name != 'README.md'])
|
|
192
|
+
print(f'目录: {base}')
|
|
193
|
+
print(f'文章: {len(md_files)} 篇 | 兴趣: {args.interest}\n')
|
|
194
|
+
|
|
195
|
+
# ====== Step 1: Clean ads ======
|
|
196
|
+
print('=== Step 1: 广告清洗 ===')
|
|
197
|
+
cleaned = 0
|
|
198
|
+
for fpath in md_files:
|
|
199
|
+
with open(fpath, 'r', encoding='utf-8') as f:
|
|
200
|
+
content = f.read()
|
|
201
|
+
new_content = clean_ads(content)
|
|
202
|
+
if len(new_content) != len(content):
|
|
203
|
+
cleaned += 1
|
|
204
|
+
with open(fpath, 'w', encoding='utf-8') as f:
|
|
205
|
+
f.write(new_content)
|
|
206
|
+
print(f' 清洗 {cleaned} 篇\n')
|
|
207
|
+
|
|
208
|
+
# ====== Step 2: Read topics from md (already set by biz_daily) ======
|
|
209
|
+
print('=== Step 2: 读取主题 ===')
|
|
210
|
+
topic_map = {}
|
|
211
|
+
for fpath in md_files:
|
|
212
|
+
with open(fpath, 'r', encoding='utf-8') as f:
|
|
213
|
+
content = f.read()
|
|
214
|
+
topic = extract_topic_from_file(content)
|
|
215
|
+
if topic not in TOPICS:
|
|
216
|
+
for t in TOPICS:
|
|
217
|
+
if t in topic: topic = t; break
|
|
218
|
+
else: topic = '学术'
|
|
219
|
+
topic_map[fpath] = topic
|
|
220
|
+
|
|
221
|
+
dist = Counter(topic_map.values())
|
|
222
|
+
print(f' 分布: {dict(dist)}\n')
|
|
223
|
+
|
|
224
|
+
# ====== Step 3: Deep summary for interest ======
|
|
225
|
+
interest_files = [f for f, t in topic_map.items() if t == args.interest]
|
|
226
|
+
if interest_files:
|
|
227
|
+
print(f'=== Step 3: [{args.interest}] 深度摘要 ({len(interest_files)}篇) ===')
|
|
228
|
+
for i, fpath in enumerate(interest_files):
|
|
229
|
+
with open(fpath, 'r', encoding='utf-8') as f:
|
|
230
|
+
content = f.read()
|
|
231
|
+
title = re.search(r'^# (.+)', content, re.MULTILINE)
|
|
232
|
+
source = re.search(r'来源:(.+)', content)
|
|
233
|
+
title = title.group(1) if title else ''
|
|
234
|
+
source = source.group(1).strip() if source else ''
|
|
235
|
+
body_match = re.search(r'## 正文\n\n(.+)', content, re.DOTALL)
|
|
236
|
+
body = body_match.group(1)[:5000] if body_match else content[500:5500]
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
prompt = INTEREST_PROMPT.format(title=title, source=source, content=body)
|
|
240
|
+
deep = call_deepseek(prompt, api_key, max_tokens=2000)
|
|
241
|
+
content = re.sub(
|
|
242
|
+
r'## AI 摘要\n\n.+?(?=\n\n---|\Z)',
|
|
243
|
+
f'## 深度解析\n\n{deep}',
|
|
244
|
+
content, flags=re.DOTALL,
|
|
245
|
+
)
|
|
246
|
+
# Update frontmatter: mark as enhanced
|
|
247
|
+
fm, body = parse_frontmatter(content)
|
|
248
|
+
if fm:
|
|
249
|
+
fm['enhanced'] = 'true'
|
|
250
|
+
write_with_frontmatter(str(fpath), fm, body)
|
|
251
|
+
else:
|
|
252
|
+
with open(fpath, 'w', encoding='utf-8') as f:
|
|
253
|
+
f.write(content)
|
|
254
|
+
print(f' [{i+1}/{len(interest_files)}] {title[:50]}')
|
|
255
|
+
time.sleep(0.3)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
print(f' [{i+1}] ERR: {e}')
|
|
258
|
+
|
|
259
|
+
# ====== Step 4: Generate action suggestions (batch) ======
|
|
260
|
+
if not args.skip_action:
|
|
261
|
+
print(f'\n=== Step 4: 生成行动建议 ===')
|
|
262
|
+
# 只处理 AI 和 学术 主题的文章
|
|
263
|
+
candidate_files = [f for f, t in topic_map.items() if t in ['AI', '学术']]
|
|
264
|
+
if candidate_files:
|
|
265
|
+
articles_data = []
|
|
266
|
+
skipped_articles = []
|
|
267
|
+
for fpath in candidate_files:
|
|
268
|
+
with open(fpath, 'r', encoding='utf-8') as f:
|
|
269
|
+
content = f.read()
|
|
270
|
+
|
|
271
|
+
fm, body = parse_frontmatter(content)
|
|
272
|
+
title = fm.get('title', '') if fm else ''
|
|
273
|
+
source = fm.get('source', '') if fm else ''
|
|
274
|
+
topic = fm.get('topic', '') if fm else ''
|
|
275
|
+
relevance = fm.get('relevance', '中') if fm else '中'
|
|
276
|
+
|
|
277
|
+
# 提取摘要
|
|
278
|
+
summary_match = re.search(r'## AI 摘要\n\n(.+?)(?=\n\n---|\n\n## |\Z)', content, re.DOTALL)
|
|
279
|
+
summary = summary_match.group(1).strip() if summary_match else ''
|
|
280
|
+
|
|
281
|
+
# 提取正文
|
|
282
|
+
body_match = re.search(r'## 正文\n\n(.+)', content, re.DOTALL)
|
|
283
|
+
body_text = body_match.group(1)[:3000] if body_match else content[:3000]
|
|
284
|
+
|
|
285
|
+
articles_data.append({
|
|
286
|
+
'title': title,
|
|
287
|
+
'source': source,
|
|
288
|
+
'topic': topic,
|
|
289
|
+
'relevance': relevance,
|
|
290
|
+
'summary': summary,
|
|
291
|
+
'content': body_text,
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
# 收集被跳过的文章(新闻/文学/投资类)
|
|
295
|
+
for fpath, topic in topic_map.items():
|
|
296
|
+
if topic not in ['AI', '学术']:
|
|
297
|
+
with open(fpath, 'r', encoding='utf-8') as f:
|
|
298
|
+
content = f.read()
|
|
299
|
+
fm, _ = parse_frontmatter(content)
|
|
300
|
+
title = fm.get('title', '') if fm else ''
|
|
301
|
+
source = fm.get('source', '') if fm else ''
|
|
302
|
+
skipped_articles.append({'title': title, 'source': source, 'topic': topic})
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
print(f' 候选文章: {len(articles_data)} 篇 (AI/学术)')
|
|
306
|
+
print(f' 跳过文章: {len(skipped_articles)} 篇 (新闻/文学/投资)')
|
|
307
|
+
action_content = generate_batch_action_suggestions(articles_data, api_key, user_profile)
|
|
308
|
+
|
|
309
|
+
# 添加被跳过的文章列表
|
|
310
|
+
if skipped_articles:
|
|
311
|
+
action_content += '\n\n---\n\n## 被跳过的文章(主题不符)\n\n'
|
|
312
|
+
for a in skipped_articles:
|
|
313
|
+
action_content += f"- 《{a['title']}》| 来源:{a['source']} | 主题:{a['topic']}\n"
|
|
314
|
+
|
|
315
|
+
# 写入行动建议文件
|
|
316
|
+
action_file = base / '行动建议.md'
|
|
317
|
+
with open(action_file, 'w', encoding='utf-8') as f:
|
|
318
|
+
f.write(f'# 行动建议 — {base.name}\n\n')
|
|
319
|
+
f.write(f'> 基于定位:{user_profile[:80]}...\n\n')
|
|
320
|
+
f.write(action_content)
|
|
321
|
+
print(f' ✓ 已生成: {action_file}')
|
|
322
|
+
except Exception as e:
|
|
323
|
+
print(f' [WARN] 行动建议生成失败: {e}')
|
|
324
|
+
else:
|
|
325
|
+
print(' 无 AI/学术 类文章,跳过行动建议')
|
|
326
|
+
else:
|
|
327
|
+
print(f'\n=== Step 4: 跳过行动建议 (--skip-action) ===')
|
|
328
|
+
|
|
329
|
+
# ====== Step 5: Move to topic folders ======
|
|
330
|
+
print(f'\n=== Step 5: 重建目录 ===')
|
|
331
|
+
# 保护收藏文件夹和状态文件
|
|
332
|
+
fav_dir = base / '收藏'
|
|
333
|
+
fav_state = base / '.fav_state.json'
|
|
334
|
+
fav_backup = None
|
|
335
|
+
if fav_dir.exists():
|
|
336
|
+
import tempfile as _tmp
|
|
337
|
+
fav_backup = Path(_tmp.mkdtemp()) / '收藏'
|
|
338
|
+
shutil.copytree(str(fav_dir), str(fav_backup))
|
|
339
|
+
shutil.rmtree(str(fav_dir))
|
|
340
|
+
for topic in TOPICS:
|
|
341
|
+
(base / topic).mkdir(exist_ok=True)
|
|
342
|
+
for fpath, topic in topic_map.items():
|
|
343
|
+
try:
|
|
344
|
+
dest = base / topic / fpath.name
|
|
345
|
+
if fpath.parent != dest.parent:
|
|
346
|
+
shutil.move(str(fpath), str(dest))
|
|
347
|
+
except:
|
|
348
|
+
pass
|
|
349
|
+
# 恢复收藏
|
|
350
|
+
if fav_backup and fav_backup.exists():
|
|
351
|
+
shutil.copytree(str(fav_backup), str(fav_dir))
|
|
352
|
+
shutil.rmtree(str(fav_backup.parent))
|
|
353
|
+
|
|
354
|
+
# ====== Step 6: README ======
|
|
355
|
+
# Preserve briefing block from biz_daily README
|
|
356
|
+
briefing_block = []
|
|
357
|
+
old_readme = base / 'README.md'
|
|
358
|
+
if old_readme.exists():
|
|
359
|
+
try:
|
|
360
|
+
with open(old_readme, 'r', encoding='utf-8') as f:
|
|
361
|
+
for line in f:
|
|
362
|
+
if '📋' in line and '简报' in line:
|
|
363
|
+
briefing_block.append(line.rstrip())
|
|
364
|
+
for line in f:
|
|
365
|
+
if line.startswith('>'):
|
|
366
|
+
stripped = line.rstrip()
|
|
367
|
+
if stripped != '>': # skip empty blockquote lines
|
|
368
|
+
briefing_block.append(stripped)
|
|
369
|
+
else:
|
|
370
|
+
break
|
|
371
|
+
break
|
|
372
|
+
except:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
action_badge = ' ✅' if (base / '行动建议.md').exists() else ''
|
|
376
|
+
lines = [
|
|
377
|
+
f'# 公众号日报 — {base.name}',
|
|
378
|
+
'',
|
|
379
|
+
]
|
|
380
|
+
if briefing_block:
|
|
381
|
+
lines.extend(briefing_block)
|
|
382
|
+
lines.append('')
|
|
383
|
+
lines.extend([
|
|
384
|
+
f'共 {len(md_files)} 篇 | 兴趣: {args.interest}',
|
|
385
|
+
'',
|
|
386
|
+
f'**[📋 查看行动建议](./行动建议.md){action_badge}** — 基于你的定位生成的可落地建议',
|
|
387
|
+
''
|
|
388
|
+
])
|
|
389
|
+
for topic in TOPICS:
|
|
390
|
+
files = sorted((base / topic).glob('*.md'))
|
|
391
|
+
if not files: continue
|
|
392
|
+
fire = ' 🔥' if topic == args.interest else ''
|
|
393
|
+
lines.append(f'## {topic}{fire} ({len(files)}篇)')
|
|
394
|
+
lines.append('')
|
|
395
|
+
for j, f in enumerate(files):
|
|
396
|
+
with open(f, 'r', encoding='utf-8') as fh:
|
|
397
|
+
t = fh.readline().strip('#').strip()
|
|
398
|
+
lines.append(f'{j+1}. [{t}](./{topic}/{f.name})')
|
|
399
|
+
lines.append('')
|
|
400
|
+
|
|
401
|
+
with open(base / 'README.md', 'w', encoding='utf-8') as f:
|
|
402
|
+
f.write('\n'.join(lines))
|
|
403
|
+
|
|
404
|
+
print(f'\n✓ 完成!')
|
|
405
|
+
for topic in TOPICS:
|
|
406
|
+
n = len(list((base / topic).glob('*.md')))
|
|
407
|
+
if n: print(f' {topic}: {n} 篇')
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == '__main__':
|
|
411
|
+
main()
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
概念图谱编译 — 扫描文章的 [[Wikilinks]] → 聚合 → DeepSeek 生成概念页。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python scripts/compile_wiki.py --api-key <key> [--limit 20] [--source output/biz-daily]
|
|
7
|
+
"""
|
|
8
|
+
import sys, os, json, re, time, hashlib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from collections import defaultdict, Counter
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
from _utils import call_deepseek, parse_frontmatter, write_with_frontmatter
|
|
14
|
+
|
|
15
|
+
# Default paths
|
|
16
|
+
SOURCE_ROOT = 'output/biz-daily'
|
|
17
|
+
OUTPUT_ROOT = 'output/wechat-vault/Wiki/Concepts'
|
|
18
|
+
|
|
19
|
+
CONCEPT_PROMPT = """为概念生成 Wiki 知识页。
|
|
20
|
+
|
|
21
|
+
概念名:{name}
|
|
22
|
+
|
|
23
|
+
参考来源(来自公众号文章):
|
|
24
|
+
{references}
|
|
25
|
+
|
|
26
|
+
请按格式返回:
|
|
27
|
+
【定义】
|
|
28
|
+
(1-2句话定义这个概念)
|
|
29
|
+
|
|
30
|
+
【关键要点】
|
|
31
|
+
- 要点1
|
|
32
|
+
- 要点2
|
|
33
|
+
- 要点3
|
|
34
|
+
|
|
35
|
+
【标签】
|
|
36
|
+
tag1, tag2, tag3
|
|
37
|
+
|
|
38
|
+
【相关概念】
|
|
39
|
+
概念A, 概念B, 概念C
|
|
40
|
+
|
|
41
|
+
要求:定义精准,要点简洁(每条≤30字),标签2-3个,相关概念2-4个。"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scan_articles(source_dir: str) -> list[dict]:
|
|
45
|
+
"""Scan all .md files, extract frontmatter + wikilinks."""
|
|
46
|
+
articles = []
|
|
47
|
+
for md_file in Path(source_dir).rglob('*.md'):
|
|
48
|
+
if md_file.name == 'README.md':
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
with open(md_file, 'r', encoding='utf-8') as f:
|
|
52
|
+
content = f.read()
|
|
53
|
+
except:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
fm, body = parse_frontmatter(content)
|
|
57
|
+
|
|
58
|
+
# Extract [[wikilinks]] with optional descriptions
|
|
59
|
+
wiki_pattern = re.findall(r'\[\[([^\]]+)\]\](?:\s*—?\s*([^\n]+))?', body)
|
|
60
|
+
wikilinks = [(name.strip(), desc.strip()) for name, desc in wiki_pattern]
|
|
61
|
+
|
|
62
|
+
if not wikilinks:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
articles.append({
|
|
66
|
+
'file': str(md_file.relative_to(source_dir)),
|
|
67
|
+
'title': fm.get('title', md_file.stem),
|
|
68
|
+
'source': fm.get('source', ''),
|
|
69
|
+
'topic': fm.get('topic', ''),
|
|
70
|
+
'tags': fm.get('tags', []),
|
|
71
|
+
'summary': _extract_summary(body),
|
|
72
|
+
'wikilinks': wikilinks,
|
|
73
|
+
})
|
|
74
|
+
return articles
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_summary(body: str) -> str:
|
|
78
|
+
"""Extract the AI summary section from article body."""
|
|
79
|
+
m = re.search(r'## (?:AI 摘要|深度解析)\n\n(.+?)(?=\n\n##|\n\n---|\Z)', body, re.DOTALL)
|
|
80
|
+
if m:
|
|
81
|
+
return m.group(1).strip()[:500]
|
|
82
|
+
# Fallback: first paragraph after metadata
|
|
83
|
+
lines = body.strip().split('\n')
|
|
84
|
+
for line in lines:
|
|
85
|
+
line = line.strip()
|
|
86
|
+
if line and not line.startswith('#') and not line.startswith('>') and not line.startswith('-'):
|
|
87
|
+
return line[:200]
|
|
88
|
+
return ''
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def aggregate_concepts(articles: list[dict]) -> dict[str, list[dict]]:
|
|
92
|
+
"""Aggregate wikilinks: concept_name -> [articles that reference it]."""
|
|
93
|
+
concept_map = defaultdict(list)
|
|
94
|
+
for art in articles:
|
|
95
|
+
seen = set()
|
|
96
|
+
for name, desc in art['wikilinks']:
|
|
97
|
+
if name in seen:
|
|
98
|
+
continue
|
|
99
|
+
seen.add(name)
|
|
100
|
+
concept_map[name].append({
|
|
101
|
+
'title': art['title'],
|
|
102
|
+
'source': art['source'],
|
|
103
|
+
'summary': art['summary'],
|
|
104
|
+
'desc': desc,
|
|
105
|
+
'file': art['file'],
|
|
106
|
+
})
|
|
107
|
+
return dict(concept_map)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def generate_concept(name: str, refs: list[dict], api_key: str) -> str | None:
|
|
111
|
+
"""Call DeepSeek to generate a concept Wiki page."""
|
|
112
|
+
# Build references section
|
|
113
|
+
ref_lines = []
|
|
114
|
+
for r in refs[:5]: # max 5 references
|
|
115
|
+
ref_lines.append(f'- [{r["title"]}]({r["source"]}):{r["summary"][:150]}')
|
|
116
|
+
ref_text = '\n'.join(ref_lines) if ref_lines else '(无详细信息)'
|
|
117
|
+
|
|
118
|
+
prompt = CONCEPT_PROMPT.format(name=name, references=ref_text)
|
|
119
|
+
try:
|
|
120
|
+
response = call_deepseek(prompt, api_key, max_tokens=800)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f' [ERR] {name}: {e}')
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# Parse response
|
|
126
|
+
definition_m = re.search(r'【定义】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
|
|
127
|
+
points_m = re.search(r'【关键要点】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
|
|
128
|
+
tags_m = re.search(r'【标签】\s*(.+)', response)
|
|
129
|
+
related_m = re.search(r'【相关概念】\s*(.+)', response)
|
|
130
|
+
|
|
131
|
+
definition = definition_m.group(1).strip() if definition_m else ''
|
|
132
|
+
points = points_m.group(1).strip() if points_m else ''
|
|
133
|
+
tags = [t.strip() for t in tags_m.group(1).split(',')] if tags_m else []
|
|
134
|
+
related = [r.strip() for r in related_m.group(1).split(',')] if related_m else []
|
|
135
|
+
|
|
136
|
+
# Build markdown body
|
|
137
|
+
body_parts = [f'# {name}\n']
|
|
138
|
+
if definition:
|
|
139
|
+
body_parts.append(f'{definition}\n\n')
|
|
140
|
+
if points:
|
|
141
|
+
body_parts.append('## 关键要点\n\n')
|
|
142
|
+
body_parts.append(points + '\n\n')
|
|
143
|
+
if related:
|
|
144
|
+
body_parts.append('## 相关概念\n\n')
|
|
145
|
+
for rc in related:
|
|
146
|
+
body_parts.append(f'- [[{rc}]]\n')
|
|
147
|
+
body_parts.append('\n')
|
|
148
|
+
body_parts.append('## 来源\n\n')
|
|
149
|
+
for r in refs[:5]:
|
|
150
|
+
body_parts.append(f'- [[{r["file"]}]] — {r["title"]}\n')
|
|
151
|
+
|
|
152
|
+
# Frontmatter
|
|
153
|
+
source_files = [r['file'] for r in refs[:5]]
|
|
154
|
+
today = time.strftime('%Y-%m-%d')
|
|
155
|
+
fm = {
|
|
156
|
+
'title': f'"{name}"',
|
|
157
|
+
'type': 'concept',
|
|
158
|
+
'tags': tags,
|
|
159
|
+
'created': today,
|
|
160
|
+
'sources': source_files,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return fm, ''.join(body_parts)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main():
|
|
167
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
168
|
+
import argparse
|
|
169
|
+
parser = argparse.ArgumentParser(description='概念图谱编译')
|
|
170
|
+
parser.add_argument('--api-key', help='DeepSeek API key (或环境变量 DEEPSEEK_API_KEY)')
|
|
171
|
+
parser.add_argument('--limit', type=int, default=20, help='最多生成概念数 (默认20)')
|
|
172
|
+
parser.add_argument('--source', default=SOURCE_ROOT, help='文章目录')
|
|
173
|
+
parser.add_argument('--output', default=OUTPUT_ROOT, help='概念页输出目录')
|
|
174
|
+
args = parser.parse_args()
|
|
175
|
+
|
|
176
|
+
api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '')
|
|
177
|
+
if not api_key:
|
|
178
|
+
print('[ERROR] 需要 DeepSeek API key')
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
source_dir = args.source
|
|
182
|
+
out_dir = Path(args.output)
|
|
183
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
|
|
185
|
+
# Step 1: Scan
|
|
186
|
+
print(f'=== Step 1: 扫描文章 ===')
|
|
187
|
+
articles = scan_articles(source_dir)
|
|
188
|
+
print(f' 找到 {len(articles)} 篇带 wikilinks 的文章')
|
|
189
|
+
|
|
190
|
+
if not articles:
|
|
191
|
+
print('No articles with wikilinks found. Exiting.')
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# Step 2: Aggregate
|
|
195
|
+
print(f'\n=== Step 2: 聚合概念 ===')
|
|
196
|
+
concept_map = aggregate_concepts(articles)
|
|
197
|
+
ranked = sorted(concept_map.items(), key=lambda x: len(x[1]), reverse=True)
|
|
198
|
+
print(f' 共 {len(ranked)} 个概念(限制 TOP {args.limit})')
|
|
199
|
+
for i, (name, refs) in enumerate(ranked[:10]):
|
|
200
|
+
print(f' {i+1}. [[{name}]] — {len(refs)} 篇文章引用')
|
|
201
|
+
|
|
202
|
+
# Step 3: Generate
|
|
203
|
+
print(f'\n=== Step 3: AI 生成概念页 ===')
|
|
204
|
+
top_concepts = ranked[:args.limit]
|
|
205
|
+
generated = 0
|
|
206
|
+
|
|
207
|
+
# Load existing concepts to skip
|
|
208
|
+
skipped = 0
|
|
209
|
+
for name, refs in top_concepts:
|
|
210
|
+
safe_name = re.sub(r'[\\/:*?"<>|]', '_', name)[:60]
|
|
211
|
+
out_file = out_dir / f'{safe_name}.md'
|
|
212
|
+
|
|
213
|
+
if out_file.exists():
|
|
214
|
+
skipped += 1
|
|
215
|
+
print(f' [SKIP] {name} (已存在)')
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
print(f' [{generated+1}/{args.limit}] {name} ({len(refs)} 引用)...')
|
|
219
|
+
result = generate_concept(name, refs, api_key)
|
|
220
|
+
if result:
|
|
221
|
+
fm, body = result
|
|
222
|
+
write_with_frontmatter(str(out_file), fm, body)
|
|
223
|
+
generated += 1
|
|
224
|
+
time.sleep(0.5)
|
|
225
|
+
|
|
226
|
+
if skipped:
|
|
227
|
+
print(f' 跳过 {skipped} 个已有概念')
|
|
228
|
+
print(f' 生成 {generated} 个新概念')
|
|
229
|
+
|
|
230
|
+
# Step 4: Index
|
|
231
|
+
print(f'\n=== Step 4: 生成索引 ===')
|
|
232
|
+
concept_files = sorted(out_dir.glob('*.md'))
|
|
233
|
+
index_lines = [
|
|
234
|
+
'# 概念索引',
|
|
235
|
+
'',
|
|
236
|
+
f'共 {len(concept_files)} 个概念 | 生成时间:{time.strftime("%Y-%m-%d %H:%M")}',
|
|
237
|
+
'',
|
|
238
|
+
'| # | 概念 | 引用数 |',
|
|
239
|
+
'|---|------|--------|',
|
|
240
|
+
]
|
|
241
|
+
for i, cf in enumerate(concept_files):
|
|
242
|
+
with open(cf, 'r', encoding='utf-8') as f:
|
|
243
|
+
content = f.read()
|
|
244
|
+
fm, _ = parse_frontmatter(content)
|
|
245
|
+
title = fm.get('title', cf.stem)
|
|
246
|
+
count = len(concept_map.get(title, []))
|
|
247
|
+
index_lines.append(f'| {i+1} | [[{title}]] | {count} |')
|
|
248
|
+
|
|
249
|
+
index_path = out_dir.parent / '00-Overview.md'
|
|
250
|
+
with open(index_path, 'w', encoding='utf-8') as f:
|
|
251
|
+
f.write('\n'.join(index_lines) + '\n')
|
|
252
|
+
|
|
253
|
+
print(f' 索引: {index_path}')
|
|
254
|
+
print(f'\n✓ 完成!共 {len(concept_files)} 个概念页')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == '__main__':
|
|
258
|
+
main()
|