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,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AI 日报生成 — 基于 git diff 分析今日知识变更,生成结构化学习日报。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python scripts/generate_review.py --api-key <key> [--date 2026-05-15] [--output Reviews]
|
|
7
|
+
"""
|
|
8
|
+
import sys, os, json, re, time, subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
from _utils import create_engine, parse_frontmatter
|
|
13
|
+
|
|
14
|
+
DAILY_PROMPT = """你是知识管理助手,基于今日文章内容生成学习日报。
|
|
15
|
+
|
|
16
|
+
今日新增文章(标题 + 来源 + 摘要):
|
|
17
|
+
{articles_text}
|
|
18
|
+
|
|
19
|
+
请按格式返回:
|
|
20
|
+
【今日焦点】
|
|
21
|
+
(2-3句话,今日最重要的知识收获)
|
|
22
|
+
|
|
23
|
+
【按主题】
|
|
24
|
+
AI:
|
|
25
|
+
- 要点1
|
|
26
|
+
- 要点2
|
|
27
|
+
|
|
28
|
+
学术:
|
|
29
|
+
- 要点1
|
|
30
|
+
- 要点2
|
|
31
|
+
|
|
32
|
+
投资:
|
|
33
|
+
- 要点1
|
|
34
|
+
|
|
35
|
+
【关键收获】
|
|
36
|
+
(1句话,今日最值得记住的一点)
|
|
37
|
+
|
|
38
|
+
要求:只基于提供的文章内容,不要编造。无对应主题则标注"(无)"。"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def scan_today_articles(target_date: str, source='output/biz-daily') -> list[dict]:
|
|
42
|
+
"""扫描指定日期的文章目录,提取 frontmatter + 摘要。"""
|
|
43
|
+
date_dir = Path(source) / target_date
|
|
44
|
+
if not date_dir.exists():
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
articles = []
|
|
48
|
+
for md_file in sorted(date_dir.rglob('*.md')):
|
|
49
|
+
if md_file.name == 'README.md' or md_file.name.startswith('.'):
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
with open(md_file, 'r', encoding='utf-8') as f:
|
|
53
|
+
content = f.read()
|
|
54
|
+
except:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
fm, body = parse_frontmatter(content)
|
|
58
|
+
if not fm:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Extract summary
|
|
62
|
+
summary = ''
|
|
63
|
+
m = re.search(r'## (?:AI 摘要|深度解析)\n\n(.+?)(?=\n\n(?:##|---)|\Z)', body, re.DOTALL)
|
|
64
|
+
if m:
|
|
65
|
+
summary = m.group(1).strip()[:300]
|
|
66
|
+
|
|
67
|
+
articles.append({
|
|
68
|
+
'title': fm.get('title', md_file.stem),
|
|
69
|
+
'source': fm.get('source', ''),
|
|
70
|
+
'topic': fm.get('topic', '学术'),
|
|
71
|
+
'tags': fm.get('tags', []),
|
|
72
|
+
'summary': summary,
|
|
73
|
+
})
|
|
74
|
+
return articles
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
79
|
+
import argparse
|
|
80
|
+
parser = argparse.ArgumentParser(description='AI 学习日报生成')
|
|
81
|
+
parser.add_argument('--api-key', help='DeepSeek API key(或环境变量 DEEPSEEK_API_KEY)')
|
|
82
|
+
parser.add_argument('--date', help='日期 YYYY-MM-DD')
|
|
83
|
+
parser.add_argument('--engine', default='deepseek', help='AI 引擎(deepseek/claude/ollama)')
|
|
84
|
+
parser.add_argument('--source', default='output/biz-daily', help='文章目录')
|
|
85
|
+
parser.add_argument('--output', default='output/reviews', help='输出目录')
|
|
86
|
+
args = parser.parse_args()
|
|
87
|
+
|
|
88
|
+
api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '')
|
|
89
|
+
if not api_key:
|
|
90
|
+
print('[ERROR] 需要 API key')
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
# Date
|
|
94
|
+
from datetime import datetime, timezone, timedelta
|
|
95
|
+
if args.date:
|
|
96
|
+
target_date = args.date
|
|
97
|
+
else:
|
|
98
|
+
tz = timezone(timedelta(hours=8))
|
|
99
|
+
target_date = datetime.now(tz).strftime('%Y-%m-%d')
|
|
100
|
+
|
|
101
|
+
print(f'=== AI 日报 — {target_date} ===\n')
|
|
102
|
+
|
|
103
|
+
# Scan articles
|
|
104
|
+
articles = scan_today_articles(target_date, args.source)
|
|
105
|
+
if not articles:
|
|
106
|
+
print(f'未找到 {target_date} 的文章,请先运行 biz_daily.py')
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
print(f'找到 {len(articles)} 篇文章')
|
|
110
|
+
|
|
111
|
+
# Topic distribution
|
|
112
|
+
from collections import Counter
|
|
113
|
+
topics = Counter(a['topic'] for a in articles)
|
|
114
|
+
print(f' 主题: {dict(topics)}')
|
|
115
|
+
|
|
116
|
+
# Format articles for prompt
|
|
117
|
+
article_texts = []
|
|
118
|
+
for a in articles:
|
|
119
|
+
article_texts.append(f'- [{a["topic"]}] {a["title"]}({a["source"]}):{a["summary"][:150]}')
|
|
120
|
+
articles_text = '\n'.join(article_texts[: 30]) # max 30 articles
|
|
121
|
+
|
|
122
|
+
# Call AI
|
|
123
|
+
print(f'\n生成日报中...')
|
|
124
|
+
engine = create_engine(args.engine, api_key)
|
|
125
|
+
try:
|
|
126
|
+
response = engine.chat(DAILY_PROMPT.format(articles_text=articles_text), max_tokens=1000)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
print(f'[ERR] AI 调用失败: {e}')
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
# Parse response
|
|
132
|
+
focus_m = re.search(r'【今日焦点】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
|
|
133
|
+
takeaway_m = re.search(r'【关键收获】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
|
|
134
|
+
|
|
135
|
+
focus = focus_m.group(1).strip() if focus_m else ''
|
|
136
|
+
takeaway = takeaway_m.group(1).strip() if takeaway_m else ''
|
|
137
|
+
|
|
138
|
+
# Output
|
|
139
|
+
out_dir = Path(args.output) / 'Daily'
|
|
140
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
out_file = out_dir / f'Daily-{target_date}.md'
|
|
142
|
+
|
|
143
|
+
with open(out_file, 'w', encoding='utf-8') as f:
|
|
144
|
+
f.write(f'---\ntitle: "学习日报 — {target_date}"\ntype: daily-review\ndate: {target_date}\ncreated: {target_date}\n---\n\n')
|
|
145
|
+
f.write(f'# 学习日报 — {target_date}\n\n')
|
|
146
|
+
f.write(f'> 共 {len(articles)} 篇文章 | 主题: {", ".join(f"{t}({c})" for t, c in topics.most_common())}\n\n')
|
|
147
|
+
f.write(f'---\n\n')
|
|
148
|
+
if focus:
|
|
149
|
+
f.write(f'## 今日焦点\n\n{focus}\n\n')
|
|
150
|
+
# Topic breakdown
|
|
151
|
+
f.write(f'## 知识输入\n\n')
|
|
152
|
+
for topic, count in topics.most_common():
|
|
153
|
+
topic_articles = [a for a in articles if a['topic'] == topic]
|
|
154
|
+
f.write(f'### {topic}({count}篇)\n\n')
|
|
155
|
+
for a in topic_articles[:8]:
|
|
156
|
+
f.write(f'- **{a["title"]}** — {a["source"]}\n')
|
|
157
|
+
if a['summary']:
|
|
158
|
+
f.write(f' {a["summary"][:200]}\n')
|
|
159
|
+
f.write('\n')
|
|
160
|
+
f.write(f'---\n\n')
|
|
161
|
+
if takeaway:
|
|
162
|
+
f.write(f'## 关键收获\n\n> {takeaway}\n\n')
|
|
163
|
+
f.write(f'*由 weflow-cli generate-review 自动生成*\n')
|
|
164
|
+
|
|
165
|
+
print(f'\n✓ 日报: {out_file}')
|
|
166
|
+
print(f' 焦点: {focus[:100] if focus else "(无)"}...')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == '__main__':
|
|
170
|
+
main()
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP Bridge — 为 MCP Server 提供微信数据库查询能力。
|
|
4
|
+
|
|
5
|
+
用法(由 MCP Server 通过 child_process 调用):
|
|
6
|
+
python scripts/mcp_bridge.py list_sessions [--limit 30]
|
|
7
|
+
python scripts/mcp_bridge.py search_messages --keyword "遥感" --talker "张三" --days 30 --limit 20
|
|
8
|
+
python scripts/mcp_bridge.py get_todos [--days 30]
|
|
9
|
+
python scripts/mcp_bridge.py get_action_suggestions [--date 2026-05-15]
|
|
10
|
+
python scripts/mcp_bridge.py get_chat_stats [--period week]
|
|
11
|
+
|
|
12
|
+
输出: JSON 格式
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import sys, os, json, hashlib, argparse
|
|
16
|
+
from datetime import datetime, timezone, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from sqlcipher3 import dbapi2 as sqlcipher
|
|
21
|
+
except ImportError:
|
|
22
|
+
print(json.dumps({"error": "请安装: pip install sqlcipher3"}))
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
from _utils import load_config, decrypt_lock, parse_frontmatter
|
|
27
|
+
|
|
28
|
+
OUTPUT_ROOT = 'output'
|
|
29
|
+
TZ = timezone(timedelta(hours=8))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def json_output(data):
|
|
33
|
+
"""Output as JSON."""
|
|
34
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ====== Database ======
|
|
38
|
+
|
|
39
|
+
def open_db(db_path, key_hex, salt_hex):
|
|
40
|
+
raw_key = f"x'{key_hex}{salt_hex}'"
|
|
41
|
+
conn = sqlcipher.connect(db_path)
|
|
42
|
+
c = conn.cursor()
|
|
43
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
44
|
+
c.execute("SELECT count(*) FROM sqlite_master")
|
|
45
|
+
return conn
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_name_map(contact_db, contact_key, contact_salt):
|
|
49
|
+
name_map = {}
|
|
50
|
+
if not contact_db or not contact_key or not os.path.exists(contact_db):
|
|
51
|
+
return name_map
|
|
52
|
+
try:
|
|
53
|
+
conn = open_db(contact_db, contact_key, contact_salt)
|
|
54
|
+
c = conn.cursor()
|
|
55
|
+
c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
|
|
56
|
+
for r in c.fetchall():
|
|
57
|
+
name_map[r[0]] = r[1]
|
|
58
|
+
conn.close()
|
|
59
|
+
except:
|
|
60
|
+
pass
|
|
61
|
+
return name_map
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_biz_keys(config):
|
|
65
|
+
nt_db = config.get('ntDbPath', '')
|
|
66
|
+
msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
|
|
67
|
+
wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
|
|
68
|
+
contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
69
|
+
contact_key_enc = config.get('contactKey', '')
|
|
70
|
+
contact_salt = config.get('contactSalt', '')
|
|
71
|
+
contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
|
|
72
|
+
biz_db = os.path.join(msg_dir, 'biz_message_0.db')
|
|
73
|
+
biz_key_enc = config.get('bizKey', '')
|
|
74
|
+
biz_salt = config.get('bizSalt', '')
|
|
75
|
+
if biz_key_enc and biz_salt:
|
|
76
|
+
biz_key = decrypt_lock(biz_key_enc)
|
|
77
|
+
else:
|
|
78
|
+
return {'error': '缺少 biz_message_0.db 密钥,请运行 python scripts/nt_decrypt.py scan --json'}
|
|
79
|
+
return {
|
|
80
|
+
'biz_db': biz_db, 'biz_key': biz_key, 'biz_salt': biz_salt,
|
|
81
|
+
'contact_db': contact_db, 'contact_key': contact_key, 'contact_salt': contact_salt,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_dbs():
|
|
86
|
+
"""Load all databases and return connections + metadata."""
|
|
87
|
+
config = load_config()
|
|
88
|
+
nt_db = config.get('ntDbPath', '')
|
|
89
|
+
if not nt_db:
|
|
90
|
+
return None, None, None, None, {"error": "未初始化,请先运行 weflow-cli init"}
|
|
91
|
+
|
|
92
|
+
nt_key = decrypt_lock(config.get('ntKey', ''))
|
|
93
|
+
nt_salt = config.get('ntSalt', '')
|
|
94
|
+
keys = get_biz_keys(config)
|
|
95
|
+
name_map = get_name_map(keys['contact_db'], keys['contact_key'], keys['contact_salt'])
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
conn = open_db(nt_db, nt_key, nt_salt)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return None, None, None, None, {"error": f"数据库连接失败: {e}"}
|
|
101
|
+
|
|
102
|
+
return conn, keys, name_map, nt_db, None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ====== Commands ======
|
|
106
|
+
|
|
107
|
+
def cmd_list_sessions(args):
|
|
108
|
+
"""List recent chat sessions."""
|
|
109
|
+
conn, keys, name_map, nt_db, err = load_dbs()
|
|
110
|
+
if err:
|
|
111
|
+
return err
|
|
112
|
+
|
|
113
|
+
c = conn.cursor()
|
|
114
|
+
limit = args.limit or 30
|
|
115
|
+
now = datetime.now(TZ)
|
|
116
|
+
start_ts = int((now - timedelta(days=90)).timestamp())
|
|
117
|
+
end_ts = int(now.timestamp())
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
121
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
122
|
+
except:
|
|
123
|
+
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
124
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
125
|
+
|
|
126
|
+
results = []
|
|
127
|
+
for talker in sessions:
|
|
128
|
+
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
129
|
+
try:
|
|
130
|
+
c.execute(f'SELECT COUNT(*), MAX(create_time) FROM "{tbl}" WHERE create_time >= ? AND create_time < ?', (start_ts, end_ts))
|
|
131
|
+
count, last_ts = c.fetchone()
|
|
132
|
+
if count > 0:
|
|
133
|
+
name = name_map.get(talker, talker)
|
|
134
|
+
last_time = datetime.fromtimestamp(last_ts, tz=TZ).strftime('%m-%d %H:%M')
|
|
135
|
+
results.append({
|
|
136
|
+
"name": name,
|
|
137
|
+
"wxid": talker,
|
|
138
|
+
"count": count,
|
|
139
|
+
"last_active": last_time,
|
|
140
|
+
})
|
|
141
|
+
except:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
conn.close()
|
|
145
|
+
results.sort(key=lambda x: x['count'], reverse=True)
|
|
146
|
+
return {"sessions": results[:limit], "total": len(results)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_search_messages(args):
|
|
150
|
+
"""Search messages by keyword."""
|
|
151
|
+
conn, keys, name_map, nt_db, err = load_dbs()
|
|
152
|
+
if err:
|
|
153
|
+
return err
|
|
154
|
+
|
|
155
|
+
keyword = args.keyword or ''
|
|
156
|
+
talker_query = args.talker or ''
|
|
157
|
+
days = args.days or 30
|
|
158
|
+
limit = args.limit or 20
|
|
159
|
+
|
|
160
|
+
now = datetime.now(TZ)
|
|
161
|
+
start_ts = int((now - timedelta(days=days)).timestamp())
|
|
162
|
+
end_ts = int(now.timestamp())
|
|
163
|
+
|
|
164
|
+
c = conn.cursor()
|
|
165
|
+
try:
|
|
166
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
167
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
168
|
+
except:
|
|
169
|
+
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
170
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
171
|
+
|
|
172
|
+
# Filter by talker if specified
|
|
173
|
+
if talker_query:
|
|
174
|
+
filtered = []
|
|
175
|
+
for s in sessions:
|
|
176
|
+
name = name_map.get(s, s)
|
|
177
|
+
if talker_query in name or talker_query in s:
|
|
178
|
+
filtered.append(s)
|
|
179
|
+
sessions = filtered
|
|
180
|
+
if not sessions:
|
|
181
|
+
conn.close()
|
|
182
|
+
return {"results": [], "message": f"未找到联系人: {talker_query}"}
|
|
183
|
+
|
|
184
|
+
results = []
|
|
185
|
+
for talker in sessions[:50]: # Limit scan scope
|
|
186
|
+
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
187
|
+
try:
|
|
188
|
+
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
189
|
+
if c.fetchone()[0] == 0:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
c.execute(f'''
|
|
193
|
+
SELECT create_time, real_sender_id, message_content
|
|
194
|
+
FROM "{tbl}"
|
|
195
|
+
WHERE create_time >= ? AND create_time < ?
|
|
196
|
+
ORDER BY create_time DESC
|
|
197
|
+
''', (start_ts, end_ts))
|
|
198
|
+
|
|
199
|
+
for ts, sender, content in c.fetchall():
|
|
200
|
+
if not content or not isinstance(content, str):
|
|
201
|
+
continue
|
|
202
|
+
if keyword and keyword.lower() not in content.lower():
|
|
203
|
+
continue
|
|
204
|
+
sender_name = name_map.get(sender, sender) if sender else name_map.get(talker, talker)
|
|
205
|
+
dt = datetime.fromtimestamp(ts, tz=TZ)
|
|
206
|
+
results.append({
|
|
207
|
+
"talker": name_map.get(talker, talker),
|
|
208
|
+
"sender": sender_name,
|
|
209
|
+
"time": dt.strftime('%Y-%m-%d %H:%M'),
|
|
210
|
+
"content": content[:500],
|
|
211
|
+
})
|
|
212
|
+
if len(results) >= limit:
|
|
213
|
+
break
|
|
214
|
+
except:
|
|
215
|
+
pass
|
|
216
|
+
if len(results) >= limit:
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
conn.close()
|
|
220
|
+
results.sort(key=lambda x: x['time'], reverse=True)
|
|
221
|
+
return {"results": results, "total": len(results), "keyword": keyword, "days": days}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_get_todos(args):
|
|
225
|
+
"""Get existing todo files."""
|
|
226
|
+
days = args.days or 30
|
|
227
|
+
todo_dir = Path(OUTPUT_ROOT)
|
|
228
|
+
results = []
|
|
229
|
+
|
|
230
|
+
# Find all 待办_*.md files
|
|
231
|
+
for f in sorted(todo_dir.glob('待办_*.md'), reverse=True):
|
|
232
|
+
content = f.read_text(encoding='utf-8')
|
|
233
|
+
# Extract todo items
|
|
234
|
+
items = []
|
|
235
|
+
for line in content.split('\n'):
|
|
236
|
+
line = line.strip()
|
|
237
|
+
if line.startswith('- [ ]') or line.startswith('- [x]'):
|
|
238
|
+
items.append(line)
|
|
239
|
+
if items:
|
|
240
|
+
results.append({
|
|
241
|
+
"file": f.name,
|
|
242
|
+
"items": items,
|
|
243
|
+
"count": len(items),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if not results:
|
|
247
|
+
return {"todos": [], "message": "暂无待办事项文件,请先运行 extract_todos.py"}
|
|
248
|
+
|
|
249
|
+
return {"todos": results}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_get_action_suggestions(args):
|
|
253
|
+
"""Get action suggestion files."""
|
|
254
|
+
date = args.date or ''
|
|
255
|
+
daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
|
|
256
|
+
|
|
257
|
+
if not daily_dir.exists():
|
|
258
|
+
return {"suggestions": [], "message": "日报目录不存在"}
|
|
259
|
+
|
|
260
|
+
if date:
|
|
261
|
+
# Specific date
|
|
262
|
+
action_file = daily_dir / date / '行动建议.md'
|
|
263
|
+
if action_file.exists():
|
|
264
|
+
content = action_file.read_text(encoding='utf-8')
|
|
265
|
+
return {"date": date, "content": content}
|
|
266
|
+
return {"suggestions": [], "message": f"未找到 {date} 的行动建议"}
|
|
267
|
+
else:
|
|
268
|
+
# Latest
|
|
269
|
+
dates = sorted([d.name for d in daily_dir.iterdir() if d.is_dir()], reverse=True)
|
|
270
|
+
for d in dates[:7]: # Check last 7 days
|
|
271
|
+
action_file = daily_dir / d / '行动建议.md'
|
|
272
|
+
if action_file.exists():
|
|
273
|
+
content = action_file.read_text(encoding='utf-8')
|
|
274
|
+
return {"date": d, "content": content}
|
|
275
|
+
return {"suggestions": [], "message": "最近 7 天无行动建议"}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def cmd_get_chat_stats(args):
|
|
279
|
+
"""Get chat statistics."""
|
|
280
|
+
conn, keys, name_map, nt_db, err = load_dbs()
|
|
281
|
+
if err:
|
|
282
|
+
return err
|
|
283
|
+
|
|
284
|
+
period = args.period or 'week'
|
|
285
|
+
now = datetime.now(TZ)
|
|
286
|
+
|
|
287
|
+
if period == 'week':
|
|
288
|
+
days_since_monday = now.weekday()
|
|
289
|
+
start_date = (now - timedelta(days=days_since_monday)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
290
|
+
else:
|
|
291
|
+
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
292
|
+
|
|
293
|
+
start_ts = int(start_date.timestamp())
|
|
294
|
+
end_ts = int(now.timestamp())
|
|
295
|
+
|
|
296
|
+
c = conn.cursor()
|
|
297
|
+
try:
|
|
298
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
299
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
300
|
+
except:
|
|
301
|
+
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
302
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
303
|
+
|
|
304
|
+
total_sent = 0
|
|
305
|
+
total_recv = 0
|
|
306
|
+
talker_counts = {}
|
|
307
|
+
hour_dist = [0] * 24
|
|
308
|
+
|
|
309
|
+
for talker in sessions:
|
|
310
|
+
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
311
|
+
try:
|
|
312
|
+
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
313
|
+
if c.fetchone()[0] == 0:
|
|
314
|
+
continue
|
|
315
|
+
c.execute(f'SELECT create_time FROM "{tbl}" WHERE create_time >= ? AND create_time < ?', (start_ts, end_ts))
|
|
316
|
+
count = 0
|
|
317
|
+
for (ts,) in c.fetchall():
|
|
318
|
+
count += 1
|
|
319
|
+
dt = datetime.fromtimestamp(ts, tz=TZ)
|
|
320
|
+
hour_dist[dt.hour] += 1
|
|
321
|
+
if count > 0:
|
|
322
|
+
name = name_map.get(talker, talker)
|
|
323
|
+
talker_counts[name] = talker_counts.get(name, 0) + count
|
|
324
|
+
total_recv += count # Approximate
|
|
325
|
+
except:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
conn.close()
|
|
329
|
+
|
|
330
|
+
# Top talkers
|
|
331
|
+
top_talkers = sorted(talker_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
332
|
+
|
|
333
|
+
# Most active hour
|
|
334
|
+
most_active_hour = max(range(24), key=lambda h: hour_dist[h])
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
"period": period,
|
|
338
|
+
"start_date": start_date.strftime('%Y-%m-%d'),
|
|
339
|
+
"end_date": now.strftime('%Y-%m-%d'),
|
|
340
|
+
"total_messages": sum(talker_counts.values()),
|
|
341
|
+
"active_contacts": len(talker_counts),
|
|
342
|
+
"top_talkers": [{"name": n, "count": c} for n, c in top_talkers],
|
|
343
|
+
"most_active_hour": f"{most_active_hour:02d}:00",
|
|
344
|
+
"hour_distribution": {f"{h:02d}:00": hour_dist[h] for h in range(24)},
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ====== Main ======
|
|
349
|
+
|
|
350
|
+
def main():
|
|
351
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
352
|
+
parser = argparse.ArgumentParser()
|
|
353
|
+
subparsers = parser.add_subparsers(dest='command')
|
|
354
|
+
|
|
355
|
+
# list_sessions
|
|
356
|
+
p = subparsers.add_parser('list_sessions')
|
|
357
|
+
p.add_argument('--limit', type=int, default=30)
|
|
358
|
+
|
|
359
|
+
# search_messages
|
|
360
|
+
p = subparsers.add_parser('search_messages')
|
|
361
|
+
p.add_argument('--keyword', default='')
|
|
362
|
+
p.add_argument('--talker', default='')
|
|
363
|
+
p.add_argument('--days', type=int, default=30)
|
|
364
|
+
p.add_argument('--limit', type=int, default=20)
|
|
365
|
+
|
|
366
|
+
# get_todos
|
|
367
|
+
p = subparsers.add_parser('get_todos')
|
|
368
|
+
p.add_argument('--days', type=int, default=30)
|
|
369
|
+
|
|
370
|
+
# get_action_suggestions
|
|
371
|
+
p = subparsers.add_parser('get_action_suggestions')
|
|
372
|
+
p.add_argument('--date', default='')
|
|
373
|
+
|
|
374
|
+
# get_chat_stats
|
|
375
|
+
p = subparsers.add_parser('get_chat_stats')
|
|
376
|
+
p.add_argument('--period', choices=['week', 'month'], default='week')
|
|
377
|
+
|
|
378
|
+
args = parser.parse_args()
|
|
379
|
+
|
|
380
|
+
handlers = {
|
|
381
|
+
'list_sessions': cmd_list_sessions,
|
|
382
|
+
'search_messages': cmd_search_messages,
|
|
383
|
+
'get_todos': cmd_get_todos,
|
|
384
|
+
'get_action_suggestions': cmd_get_action_suggestions,
|
|
385
|
+
'get_chat_stats': cmd_get_chat_stats,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
handler = handlers.get(args.command)
|
|
389
|
+
if not handler:
|
|
390
|
+
json_output({"error": f"未知命令: {args.command}", "available": list(handlers.keys())})
|
|
391
|
+
sys.exit(1)
|
|
392
|
+
|
|
393
|
+
result = handler(args)
|
|
394
|
+
json_output(result)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == '__main__':
|
|
398
|
+
main()
|