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,796 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
年度报告生成器 — 汇总全年聊天 + 公众号数据,生成精美 HTML 报告。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python scripts/annual_report.py 2026
|
|
7
|
+
python scripts/annual_report.py 2026 --output report.html --share
|
|
8
|
+
|
|
9
|
+
输出: output/annual-report-2026.html
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys, os, json, hashlib
|
|
13
|
+
from datetime import datetime, timezone, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from collections import defaultdict, Counter
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from sqlcipher3 import dbapi2 as sqlcipher
|
|
19
|
+
except ImportError:
|
|
20
|
+
print(json.dumps({"error": "请安装: pip install sqlcipher3"}))
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
|
+
from _utils import load_config, decrypt_lock, parse_frontmatter, call_deepseek
|
|
25
|
+
|
|
26
|
+
TZ = timezone(timedelta(hours=8))
|
|
27
|
+
OUTPUT_ROOT = 'output'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ====== Database ======
|
|
31
|
+
|
|
32
|
+
def open_db(db_path, key_hex, salt_hex):
|
|
33
|
+
raw_key = f"x'{key_hex}{salt_hex}'"
|
|
34
|
+
conn = sqlcipher.connect(db_path)
|
|
35
|
+
c = conn.cursor()
|
|
36
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
37
|
+
c.execute("SELECT count(*) FROM sqlite_master")
|
|
38
|
+
return conn
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_name_map(contact_db, contact_key, contact_salt):
|
|
42
|
+
name_map = {}
|
|
43
|
+
if not contact_db or not contact_key or not os.path.exists(contact_db):
|
|
44
|
+
return name_map
|
|
45
|
+
try:
|
|
46
|
+
conn = open_db(contact_db, contact_key, contact_salt)
|
|
47
|
+
c = conn.cursor()
|
|
48
|
+
c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
|
|
49
|
+
for r in c.fetchall():
|
|
50
|
+
name_map[r[0]] = r[1]
|
|
51
|
+
conn.close()
|
|
52
|
+
except:
|
|
53
|
+
pass
|
|
54
|
+
return name_map
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ====== Data Collection ======
|
|
58
|
+
|
|
59
|
+
def collect_chat_stats(conn, year, name_map):
|
|
60
|
+
"""Collect monthly message stats and top contacts."""
|
|
61
|
+
monthly_msg = [0] * 12
|
|
62
|
+
monthly_chars = [0] * 12
|
|
63
|
+
contact_count = Counter()
|
|
64
|
+
hourly = [0] * 24
|
|
65
|
+
sender_count = Counter()
|
|
66
|
+
|
|
67
|
+
c = conn.cursor()
|
|
68
|
+
try:
|
|
69
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
70
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
71
|
+
except:
|
|
72
|
+
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
73
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
74
|
+
|
|
75
|
+
for talker in sessions[:100]:
|
|
76
|
+
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
77
|
+
try:
|
|
78
|
+
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
79
|
+
if c.fetchone()[0] == 0:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
c.execute(f'''
|
|
83
|
+
SELECT create_time, real_sender_id, message_content
|
|
84
|
+
FROM "{tbl}"
|
|
85
|
+
WHERE create_time >= ? AND create_time < ?
|
|
86
|
+
''', (
|
|
87
|
+
int(datetime(year, 1, 1, tzinfo=TZ).timestamp()),
|
|
88
|
+
int(datetime(year + 1, 1, 1, tzinfo=TZ).timestamp()),
|
|
89
|
+
))
|
|
90
|
+
|
|
91
|
+
for ts, sender_id, content in c.fetchall():
|
|
92
|
+
if not isinstance(content, str) or len(content) < 1:
|
|
93
|
+
continue
|
|
94
|
+
dt = datetime.fromtimestamp(ts, tz=TZ)
|
|
95
|
+
if dt.year != year:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
month_idx = dt.month - 1
|
|
99
|
+
monthly_msg[month_idx] += 1
|
|
100
|
+
monthly_chars[month_idx] += len(content)
|
|
101
|
+
hourly[dt.hour] += 1
|
|
102
|
+
|
|
103
|
+
talker_name = name_map.get(talker, talker)
|
|
104
|
+
contact_count[talker_name] += 1
|
|
105
|
+
sender_count[str(sender_id)] += 1
|
|
106
|
+
except:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
# Top contacts (exclude system/self)
|
|
110
|
+
top_contacts = [(name, cnt) for name, cnt in contact_count.most_common(15)
|
|
111
|
+
if not name.startswith('wxid_')]
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
'monthly_msg': monthly_msg,
|
|
115
|
+
'monthly_chars': monthly_chars,
|
|
116
|
+
'hourly': hourly,
|
|
117
|
+
'top_contacts': top_contacts[:10],
|
|
118
|
+
'total_msgs': sum(monthly_msg),
|
|
119
|
+
'total_chars': sum(monthly_chars),
|
|
120
|
+
'contact_count': len([c for c in contact_count if not c.startswith('wxid_')]),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def collect_article_stats(year):
|
|
125
|
+
"""Collect article stats from biz-daily output."""
|
|
126
|
+
biz_dir = Path(OUTPUT_ROOT) / 'biz-daily'
|
|
127
|
+
monthly_topic = [defaultdict(int) for _ in range(12)]
|
|
128
|
+
monthly_count = [0] * 12
|
|
129
|
+
total_articles = 0
|
|
130
|
+
all_sources = Counter()
|
|
131
|
+
all_tags = Counter()
|
|
132
|
+
|
|
133
|
+
for date_dir in biz_dir.iterdir():
|
|
134
|
+
if not date_dir.is_dir():
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
d = datetime.strptime(date_dir.name, '%Y-%m-%d')
|
|
138
|
+
if d.year != year:
|
|
139
|
+
continue
|
|
140
|
+
except:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
month_idx = d.month - 1
|
|
144
|
+
for topic_dir in date_dir.iterdir():
|
|
145
|
+
if not topic_dir.is_dir() or topic_dir.name.startswith('.'):
|
|
146
|
+
continue
|
|
147
|
+
topic = topic_dir.name
|
|
148
|
+
for md_file in topic_dir.glob('*.md'):
|
|
149
|
+
if md_file.name == 'README.md' or md_file.name.startswith('.'):
|
|
150
|
+
continue
|
|
151
|
+
try:
|
|
152
|
+
content = md_file.read_text(encoding='utf-8')
|
|
153
|
+
meta, _ = parse_frontmatter(content)
|
|
154
|
+
except:
|
|
155
|
+
continue
|
|
156
|
+
total_articles += 1
|
|
157
|
+
monthly_count[month_idx] += 1
|
|
158
|
+
monthly_topic[month_idx][topic] += 1
|
|
159
|
+
if meta.get('source'):
|
|
160
|
+
all_sources[meta['source']] += 1
|
|
161
|
+
if meta.get('tags'):
|
|
162
|
+
for tag in meta['tags']:
|
|
163
|
+
all_tags[tag] += 1
|
|
164
|
+
|
|
165
|
+
# Aggregate topic totals across months
|
|
166
|
+
topic_totals = defaultdict(int)
|
|
167
|
+
for mt in monthly_topic:
|
|
168
|
+
for t, c in mt.items():
|
|
169
|
+
topic_totals[t] += c
|
|
170
|
+
|
|
171
|
+
# Monthly topic data for stacked chart
|
|
172
|
+
topics_set = sorted(topic_totals.keys())
|
|
173
|
+
monthly_topic_data = {t: [monthly_topic[i].get(t, 0) for i in range(12)] for t in topics_set}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
'monthly_count': monthly_count,
|
|
177
|
+
'monthly_topic': monthly_topic_data,
|
|
178
|
+
'topic_totals': dict(topic_totals),
|
|
179
|
+
'total_articles': total_articles,
|
|
180
|
+
'top_sources': all_sources.most_common(8),
|
|
181
|
+
'top_tags': all_tags.most_common(15),
|
|
182
|
+
'active_months': sum(1 for c in monthly_count if c > 0),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def collect_payments(conn, year):
|
|
187
|
+
"""Collect payment stats from messages."""
|
|
188
|
+
monthly_income = [0] * 12
|
|
189
|
+
monthly_expense = [0] * 12
|
|
190
|
+
total_count = 0
|
|
191
|
+
|
|
192
|
+
c = conn.cursor()
|
|
193
|
+
# Use chatroom "微信支付" or filter by content pattern
|
|
194
|
+
try:
|
|
195
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
196
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
197
|
+
except:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
pay_talkers = []
|
|
201
|
+
for t in sessions:
|
|
202
|
+
if 'pay' in t.lower() or '支付' in t.lower() or 'weixin' in t.lower() or 'gh_22fbb5a5c21c' in t:
|
|
203
|
+
pay_talkers.append(t)
|
|
204
|
+
|
|
205
|
+
for talker in pay_talkers[:5]:
|
|
206
|
+
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
207
|
+
try:
|
|
208
|
+
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
209
|
+
if c.fetchone()[0] == 0:
|
|
210
|
+
continue
|
|
211
|
+
c.execute(f'''
|
|
212
|
+
SELECT create_time, message_content
|
|
213
|
+
FROM "{tbl}"
|
|
214
|
+
WHERE create_time >= ? AND create_time < ?
|
|
215
|
+
''', (
|
|
216
|
+
int(datetime(year, 1, 1, tzinfo=TZ).timestamp()),
|
|
217
|
+
int(datetime(year + 1, 1, 1, tzinfo=TZ).timestamp()),
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
for ts, content in c.fetchall():
|
|
221
|
+
if not isinstance(content, str):
|
|
222
|
+
continue
|
|
223
|
+
dt = datetime.fromtimestamp(ts, tz=TZ)
|
|
224
|
+
if dt.year != year:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
total_count += 1
|
|
228
|
+
# Extract amount
|
|
229
|
+
import re
|
|
230
|
+
amounts = re.findall(r'[¥¥](\d+\.?\d*)', content)
|
|
231
|
+
for amt in amounts:
|
|
232
|
+
val = float(amt)
|
|
233
|
+
month_idx = dt.month - 1
|
|
234
|
+
if '收入' in content or '入账' in content or '收款' in content:
|
|
235
|
+
monthly_income[month_idx] += val
|
|
236
|
+
else:
|
|
237
|
+
monthly_expense[month_idx] += val
|
|
238
|
+
except:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
if total_count == 0:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
'monthly_income': [round(x, 2) for x in monthly_income],
|
|
246
|
+
'monthly_expense': [round(x, 2) for x in monthly_expense],
|
|
247
|
+
'total_count': total_count,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ====== AI Summary ======
|
|
252
|
+
|
|
253
|
+
def generate_ai_summary(stats: dict, api_key: str) -> str:
|
|
254
|
+
"""Generate personalized year summary with AI."""
|
|
255
|
+
if not api_key:
|
|
256
|
+
return ''
|
|
257
|
+
|
|
258
|
+
prompt = f"""你是一个年度报告生成助手。基于以下数据,为用户的 {stats['year']} 年生成一段 200-300 字的个人年度总结。
|
|
259
|
+
|
|
260
|
+
数据:
|
|
261
|
+
- 聊天消息总数:{stats.get('total_msgs', 0):,}
|
|
262
|
+
- 最常联系的人:{', '.join(f'{n}({c}条)' for n, c in stats.get('top_contacts', [])[:5])}
|
|
263
|
+
- 公众号文章阅读:{stats.get('total_articles', 0)} 篇
|
|
264
|
+
- 文章主题分布:{json.dumps(stats.get('topic_totals', {}), ensure_ascii=False)}
|
|
265
|
+
- 最爱公众号:{', '.join(f'{n}' for n, _ in stats.get('top_sources', [])[:5])}
|
|
266
|
+
- 活跃时段:{
|
|
267
|
+
f'{stats.get("hourly", [0]*24).index(max(stats.get("hourly", [0]*24)))}点最活跃' if stats.get('hourly') else '无数据'
|
|
268
|
+
}
|
|
269
|
+
- 活跃月份:{stats.get('active_months', 0)} 个月有公众号阅读
|
|
270
|
+
|
|
271
|
+
请用温暖、鼓励的中文写出总结(不要序号或列表,一个连贯的段落),突出你的数字生活亮点。"""
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
return call_deepseek(prompt, api_key, max_tokens=500, timeout=60)
|
|
275
|
+
except:
|
|
276
|
+
return ''
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ====== HTML Generation ======
|
|
280
|
+
|
|
281
|
+
MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
|
282
|
+
|
|
283
|
+
TOPIC_COLORS = {
|
|
284
|
+
'AI': '#8b5cf6',
|
|
285
|
+
'学术': '#3b82f6',
|
|
286
|
+
'新闻': '#f59e0b',
|
|
287
|
+
'文学': '#ec4899',
|
|
288
|
+
'投资': '#10b981',
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
PALETTE = [
|
|
292
|
+
'#818cf8', '#c084fc', '#f472b6', '#34d399', '#fbbf24',
|
|
293
|
+
'#60a5fa', '#f87171', '#a78bfa', '#4ade80', '#fb923c',
|
|
294
|
+
'#38bdf8', '#e879f9', '#22d3ee', '#facc15', '#ff6b6b',
|
|
295
|
+
'#45b7d1', '#f8a5c2', '#7bed9f', '#70a1ff', '#eccc68',
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def color_for(index: int) -> str:
|
|
300
|
+
return PALETTE[index % len(PALETTE)]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def generate_html(year: int, stats: dict, ai_summary: str, share_mode: bool = False) -> str:
|
|
304
|
+
"""Generate self-contained HTML report."""
|
|
305
|
+
|
|
306
|
+
top_contacts_json = json.dumps(stats.get('top_contacts', [])[:10], ensure_ascii=False)
|
|
307
|
+
monthly_topic_json = json.dumps(stats.get('monthly_topic', {}), ensure_ascii=False)
|
|
308
|
+
monthly_count_json = json.dumps(stats.get('monthly_count', []), ensure_ascii=False)
|
|
309
|
+
topic_totals_json = json.dumps(stats.get('topic_totals', {}), ensure_ascii=False)
|
|
310
|
+
hourly_json = json.dumps(stats.get('hourly', [0] * 24), ensure_ascii=False)
|
|
311
|
+
monthly_msg_json = json.dumps(stats.get('monthly_msg', [0] * 12), ensure_ascii=False)
|
|
312
|
+
payments = stats.get('payments')
|
|
313
|
+
|
|
314
|
+
# Payment section
|
|
315
|
+
payment_html = ''
|
|
316
|
+
if payments:
|
|
317
|
+
payment_html = f'''
|
|
318
|
+
<div class="section">
|
|
319
|
+
<h2>💰 财务概览</h2>
|
|
320
|
+
<div class="chart-box"><canvas id="paymentChart"></canvas></div>
|
|
321
|
+
</div>'''
|
|
322
|
+
|
|
323
|
+
# AI summary section
|
|
324
|
+
ai_html = ''
|
|
325
|
+
if ai_summary:
|
|
326
|
+
ai_html = f'''
|
|
327
|
+
<div class="section highlight">
|
|
328
|
+
<h2>🤖 AI 年度总结</h2>
|
|
329
|
+
<p class="ai-summary">{ai_summary}</p>
|
|
330
|
+
</div>'''
|
|
331
|
+
|
|
332
|
+
# Share mode: card-style layout
|
|
333
|
+
share_class = 'share-mode' if share_mode else ''
|
|
334
|
+
|
|
335
|
+
top_sources = stats.get('top_sources', [])[:8]
|
|
336
|
+
top_tags = stats.get('top_tags', [])[:12]
|
|
337
|
+
|
|
338
|
+
# Build a robust topic color map with fallback to palette
|
|
339
|
+
topic_keys = list(stats.get('topic_totals', {}).keys())
|
|
340
|
+
topic_color_map = {}
|
|
341
|
+
for i, t in enumerate(topic_keys):
|
|
342
|
+
topic_color_map[t] = TOPIC_COLORS.get(t, PALETTE[i % len(PALETTE)])
|
|
343
|
+
topic_color_map_js = json.dumps(topic_color_map, ensure_ascii=False)
|
|
344
|
+
|
|
345
|
+
# For monthly message chart: only show months with data (up to current month)
|
|
346
|
+
monthly_msg = stats.get('monthly_msg', [0]*12)
|
|
347
|
+
now_month = datetime.now(TZ).month
|
|
348
|
+
# Truncate to current month
|
|
349
|
+
display_months = MONTH_LABELS[:now_month]
|
|
350
|
+
display_msg = monthly_msg[:now_month]
|
|
351
|
+
|
|
352
|
+
# For article chart: same
|
|
353
|
+
monthly_count = stats.get('monthly_count', [0]*12)
|
|
354
|
+
display_article_months = MONTH_LABELS[:now_month]
|
|
355
|
+
display_article_count = monthly_count[:now_month]
|
|
356
|
+
|
|
357
|
+
html = f'''<!DOCTYPE html>
|
|
358
|
+
<html lang="zh-CN">
|
|
359
|
+
<head>
|
|
360
|
+
<meta charset="UTF-8">
|
|
361
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
362
|
+
<title>{year} 年度数字生活报告</title>
|
|
363
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
364
|
+
<style>
|
|
365
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
366
|
+
body {{
|
|
367
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", sans-serif;
|
|
368
|
+
background: #0a0a1a;
|
|
369
|
+
color: #e2e8f0;
|
|
370
|
+
min-height: 100vh;
|
|
371
|
+
line-height: 1.6;
|
|
372
|
+
}}
|
|
373
|
+
.container {{ max-width: 800px; margin: 0 auto; padding: 40px 20px; }}
|
|
374
|
+
|
|
375
|
+
/* Hero */
|
|
376
|
+
.hero {{
|
|
377
|
+
text-align: center;
|
|
378
|
+
padding: 60px 20px 50px;
|
|
379
|
+
position: relative;
|
|
380
|
+
}}
|
|
381
|
+
.hero .year-badge {{
|
|
382
|
+
font-size: 96px;
|
|
383
|
+
font-weight: 900;
|
|
384
|
+
background: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
|
|
385
|
+
-webkit-background-clip: text;
|
|
386
|
+
-webkit-text-fill-color: transparent;
|
|
387
|
+
background-clip: text;
|
|
388
|
+
line-height: 1;
|
|
389
|
+
}}
|
|
390
|
+
.hero .subtitle {{
|
|
391
|
+
font-size: 20px;
|
|
392
|
+
color: #64748b;
|
|
393
|
+
margin-top: 12px;
|
|
394
|
+
font-weight: 300;
|
|
395
|
+
letter-spacing: 4px;
|
|
396
|
+
}}
|
|
397
|
+
.hero .stats-row {{
|
|
398
|
+
display: flex;
|
|
399
|
+
justify-content: center;
|
|
400
|
+
gap: 40px;
|
|
401
|
+
margin-top: 30px;
|
|
402
|
+
flex-wrap: wrap;
|
|
403
|
+
}}
|
|
404
|
+
.stat-item {{ text-align: center; }}
|
|
405
|
+
.stat-item .num {{
|
|
406
|
+
font-size: 36px;
|
|
407
|
+
font-weight: 800;
|
|
408
|
+
background: linear-gradient(135deg, #e2e8f0, #94a3b8);
|
|
409
|
+
-webkit-background-clip: text;
|
|
410
|
+
-webkit-text-fill-color: transparent;
|
|
411
|
+
background-clip: text;
|
|
412
|
+
}}
|
|
413
|
+
.stat-item .label {{ font-size: 13px; color: #475569; margin-top: 4px; }}
|
|
414
|
+
|
|
415
|
+
/* Sections */
|
|
416
|
+
.section {{
|
|
417
|
+
background: #111133;
|
|
418
|
+
border: 1px solid #1e1e4a;
|
|
419
|
+
border-radius: 20px;
|
|
420
|
+
padding: 32px 28px;
|
|
421
|
+
margin-bottom: 24px;
|
|
422
|
+
}}
|
|
423
|
+
.section.highlight {{
|
|
424
|
+
background: linear-gradient(135deg, #1a1040, #0d1b3e);
|
|
425
|
+
border-color: #2d2d6e;
|
|
426
|
+
}}
|
|
427
|
+
.section h2 {{
|
|
428
|
+
font-size: 20px;
|
|
429
|
+
font-weight: 700;
|
|
430
|
+
margin-bottom: 24px;
|
|
431
|
+
display: flex;
|
|
432
|
+
align-items: center;
|
|
433
|
+
gap: 8px;
|
|
434
|
+
}}
|
|
435
|
+
.chart-box {{ max-width: 100%; margin: 0 auto; }}
|
|
436
|
+
.chart-box canvas {{ max-height: 300px; }}
|
|
437
|
+
|
|
438
|
+
/* AI Summary */
|
|
439
|
+
.ai-summary {{
|
|
440
|
+
font-size: 16px;
|
|
441
|
+
line-height: 2;
|
|
442
|
+
color: #cbd5e1;
|
|
443
|
+
}}
|
|
444
|
+
|
|
445
|
+
/* Contact list */
|
|
446
|
+
.contact-list {{
|
|
447
|
+
display: flex;
|
|
448
|
+
flex-wrap: wrap;
|
|
449
|
+
gap: 8px;
|
|
450
|
+
margin-top: 16px;
|
|
451
|
+
}}
|
|
452
|
+
.contact-chip {{
|
|
453
|
+
padding: 6px 14px;
|
|
454
|
+
border-radius: 20px;
|
|
455
|
+
font-size: 13px;
|
|
456
|
+
font-weight: 500;
|
|
457
|
+
}}
|
|
458
|
+
|
|
459
|
+
/* Tags */
|
|
460
|
+
.tag-cloud {{
|
|
461
|
+
display: flex;
|
|
462
|
+
flex-wrap: wrap;
|
|
463
|
+
gap: 6px;
|
|
464
|
+
margin-top: 8px;
|
|
465
|
+
}}
|
|
466
|
+
.tag-item {{
|
|
467
|
+
padding: 4px 10px;
|
|
468
|
+
border-radius: 8px;
|
|
469
|
+
font-size: 12px;
|
|
470
|
+
background: #1e1e4a;
|
|
471
|
+
color: #94a3b8;
|
|
472
|
+
}}
|
|
473
|
+
|
|
474
|
+
/* Sources */
|
|
475
|
+
.source-list {{
|
|
476
|
+
display: grid;
|
|
477
|
+
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
478
|
+
gap: 8px;
|
|
479
|
+
margin-top: 8px;
|
|
480
|
+
}}
|
|
481
|
+
.source-item {{
|
|
482
|
+
padding: 8px 12px;
|
|
483
|
+
border-radius: 10px;
|
|
484
|
+
background: #1a1a40;
|
|
485
|
+
font-size: 12px;
|
|
486
|
+
text-align: center;
|
|
487
|
+
border: 2px solid #334155;
|
|
488
|
+
}}
|
|
489
|
+
.source-item .cnt {{ font-size: 18px; font-weight: 700; display: block; }}
|
|
490
|
+
|
|
491
|
+
/* Footer */
|
|
492
|
+
.footer {{
|
|
493
|
+
text-align: center;
|
|
494
|
+
padding: 40px 20px;
|
|
495
|
+
color: #334155;
|
|
496
|
+
font-size: 13px;
|
|
497
|
+
}}
|
|
498
|
+
|
|
499
|
+
/* Share mode */
|
|
500
|
+
.share-mode .container {{ max-width: 500px; }}
|
|
501
|
+
.share-mode .hero .year-badge {{ font-size: 72px; }}
|
|
502
|
+
.share-mode .section {{ padding: 24px 20px; }}
|
|
503
|
+
|
|
504
|
+
@media (max-width: 600px) {{
|
|
505
|
+
.container {{ padding: 20px 14px; }}
|
|
506
|
+
.hero .year-badge {{ font-size: 72px; }}
|
|
507
|
+
.hero .stats-row {{ gap: 20px; }}
|
|
508
|
+
.stat-item .num {{ font-size: 28px; }}
|
|
509
|
+
.section {{ padding: 24px 18px; }}
|
|
510
|
+
}}
|
|
511
|
+
</style>
|
|
512
|
+
</head>
|
|
513
|
+
<body>
|
|
514
|
+
<div class="container {share_class}">
|
|
515
|
+
|
|
516
|
+
<div class="hero">
|
|
517
|
+
<div class="year-badge">{year}</div>
|
|
518
|
+
<div class="subtitle">我的数字生活年度报告</div>
|
|
519
|
+
<div class="stats-row">
|
|
520
|
+
<div class="stat-item">
|
|
521
|
+
<div class="num">{stats.get('total_msgs', 0):,}</div>
|
|
522
|
+
<div class="label">条消息</div>
|
|
523
|
+
</div>
|
|
524
|
+
<div class="stat-item">
|
|
525
|
+
<div class="num">{stats.get('total_articles', 0)}</div>
|
|
526
|
+
<div class="label">篇文章</div>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="stat-item">
|
|
529
|
+
<div class="num">{stats.get('contact_count', 0)}</div>
|
|
530
|
+
<div class="label">位联系人</div>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
|
|
535
|
+
{ai_html}
|
|
536
|
+
|
|
537
|
+
<div class="section">
|
|
538
|
+
<h2>💬 每月消息量</h2>
|
|
539
|
+
<div class="chart-box"><canvas id="msgChart"></canvas></div>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
<div class="section">
|
|
543
|
+
<h2>📚 文章阅读趋势</h2>
|
|
544
|
+
<div class="chart-box"><canvas id="articleChart"></canvas></div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<div class="section">
|
|
548
|
+
<h2>🎯 主题分布</h2>
|
|
549
|
+
<div class="chart-box"><canvas id="topicChart"></canvas></div>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
<div class="section">
|
|
553
|
+
<h2>⏰ 活跃时段</h2>
|
|
554
|
+
<div class="chart-box"><canvas id="hourlyChart"></canvas></div>
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
<div class="section">
|
|
558
|
+
<h2>👥 最常联系的人</h2>
|
|
559
|
+
<div class="contact-list">
|
|
560
|
+
{''.join(f'<span class="contact-chip" style="background:{color_for(i)}20;color:{color_for(i)}">{n} · {c}条</span>' for i, (n, c) in enumerate(stats.get('top_contacts', [])[:8]))}
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
<div class="section">
|
|
565
|
+
<h2>📰 最爱公众号</h2>
|
|
566
|
+
<div class="source-list">
|
|
567
|
+
{''.join(f'<div class="source-item" style="border-color:{color_for(i)}40"><span class="cnt" style="color:{color_for(i)}">{c}</span><span style="color:#94a3b8">{n}</span></div>' for i, (n, c) in enumerate(top_sources))}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
<div class="section">
|
|
572
|
+
<h2>🏷️ 热门标签</h2>
|
|
573
|
+
<div class="tag-cloud">
|
|
574
|
+
{''.join(f'<span class="tag-item">{t[0]} · {t[1]}</span>' for t in top_tags)}
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
|
|
578
|
+
{payment_html}
|
|
579
|
+
|
|
580
|
+
<div class="footer">
|
|
581
|
+
由 WeFlow CLI 生成 · {datetime.now(TZ).strftime('%Y-%m-%d %H:%M')}
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
<script>
|
|
587
|
+
const chartDefaults = {{
|
|
588
|
+
responsive: true,
|
|
589
|
+
maintainAspectRatio: true,
|
|
590
|
+
plugins: {{ legend: {{ labels: {{ color: '#94a3b8', font: {{ size: 12 }} }} }} }},
|
|
591
|
+
scales: {{
|
|
592
|
+
x: {{ ticks: {{ color: '#64748b', font: {{ size: 11 }} }}, grid: {{ color: '#1e1e4a' }} }},
|
|
593
|
+
y: {{ ticks: {{ color: '#64748b', font: {{ size: 11 }} }}, grid: {{ color: '#1e1e4a' }}, beginAtZero: true }},
|
|
594
|
+
}},
|
|
595
|
+
}};
|
|
596
|
+
|
|
597
|
+
// Monthly messages
|
|
598
|
+
new Chart(document.getElementById('msgChart'), {{
|
|
599
|
+
type: 'bar',
|
|
600
|
+
data: {{
|
|
601
|
+
labels: {json.dumps(display_months, ensure_ascii=False)},
|
|
602
|
+
datasets: [{{
|
|
603
|
+
label: '消息数',
|
|
604
|
+
data: {json.dumps(display_msg, ensure_ascii=False)},
|
|
605
|
+
backgroundColor: ['#818cf8','#c084fc','#f472b6','#34d399','#fbbf24','#60a5fa','#f87171','#a78bfa','#4ade80','#fb923c','#38bdf8','#facc15'].slice(0, {len(display_msg)}),
|
|
606
|
+
borderWidth: 1,
|
|
607
|
+
borderRadius: 6,
|
|
608
|
+
}}],
|
|
609
|
+
}},
|
|
610
|
+
options: chartDefaults,
|
|
611
|
+
}});
|
|
612
|
+
|
|
613
|
+
// Monthly articles
|
|
614
|
+
const topicDatasets = {json.dumps(monthly_topic_json, ensure_ascii=False)};
|
|
615
|
+
const topicColors = {topic_color_map_js};
|
|
616
|
+
new Chart(document.getElementById('articleChart'), {{
|
|
617
|
+
type: 'bar',
|
|
618
|
+
data: {{
|
|
619
|
+
labels: {json.dumps(display_article_months, ensure_ascii=False)},
|
|
620
|
+
datasets: Object.entries(topicDatasets).map(([topic, data]) => ({{
|
|
621
|
+
label: topic,
|
|
622
|
+
data: data.slice(0, {len(display_article_months)}),
|
|
623
|
+
backgroundColor: (topicColors[topic] || '#64748b') + '80',
|
|
624
|
+
borderColor: topicColors[topic] || '#64748b',
|
|
625
|
+
borderWidth: 1,
|
|
626
|
+
borderRadius: 4,
|
|
627
|
+
}})),
|
|
628
|
+
}},
|
|
629
|
+
options: {{
|
|
630
|
+
...chartDefaults,
|
|
631
|
+
scales: {{
|
|
632
|
+
...chartDefaults.scales,
|
|
633
|
+
x: {{ ...chartDefaults.scales.x, stacked: true }},
|
|
634
|
+
y: {{ ...chartDefaults.scales.y, stacked: true, title: {{ display: true, text: '篇', color: '#94a3b8' }} }},
|
|
635
|
+
}},
|
|
636
|
+
}},
|
|
637
|
+
}});
|
|
638
|
+
|
|
639
|
+
// Topic donut
|
|
640
|
+
const topicTotals = {json.dumps(topic_totals_json)};
|
|
641
|
+
new Chart(document.getElementById('topicChart'), {{
|
|
642
|
+
type: 'doughnut',
|
|
643
|
+
data: {{
|
|
644
|
+
labels: Object.keys(topicTotals),
|
|
645
|
+
datasets: [{{
|
|
646
|
+
data: Object.values(topicTotals),
|
|
647
|
+
backgroundColor: Object.keys(topicTotals).map(function(t) {{ return (topicColors[t] || '#64748b') + 'c0'; }}),
|
|
648
|
+
borderColor: Object.keys(topicTotals).map(function(t) {{ return topicColors[t] || '#64748b'; }}),
|
|
649
|
+
borderWidth: 2,
|
|
650
|
+
}}],
|
|
651
|
+
}},
|
|
652
|
+
options: {{
|
|
653
|
+
responsive: true,
|
|
654
|
+
plugins: {{
|
|
655
|
+
legend: {{
|
|
656
|
+
position: 'bottom',
|
|
657
|
+
labels: {{ color: '#94a3b8', padding: 16, font: {{ size: 13 }} }}
|
|
658
|
+
}},
|
|
659
|
+
}},
|
|
660
|
+
}},
|
|
661
|
+
}});
|
|
662
|
+
|
|
663
|
+
// Hourly activity
|
|
664
|
+
new Chart(document.getElementById('hourlyChart'), {{
|
|
665
|
+
type: 'line',
|
|
666
|
+
data: {{
|
|
667
|
+
labels: Array.from({{length: 24}}, (_, i) => i + '时'),
|
|
668
|
+
datasets: [{{
|
|
669
|
+
label: '消息量',
|
|
670
|
+
data: {json.dumps(hourly_json)},
|
|
671
|
+
borderColor: '#818cf8',
|
|
672
|
+
backgroundColor: '#818cf820',
|
|
673
|
+
fill: true,
|
|
674
|
+
tension: 0.4,
|
|
675
|
+
pointRadius: 2,
|
|
676
|
+
pointHoverRadius: 6,
|
|
677
|
+
}}],
|
|
678
|
+
}},
|
|
679
|
+
options: {{
|
|
680
|
+
...chartDefaults,
|
|
681
|
+
scales: {{
|
|
682
|
+
...chartDefaults.scales,
|
|
683
|
+
y: {{ ...chartDefaults.scales.y, title: {{ display: true, text: '条', color: '#94a3b8' }} }},
|
|
684
|
+
}},
|
|
685
|
+
}},
|
|
686
|
+
}});
|
|
687
|
+
'''
|
|
688
|
+
|
|
689
|
+
if payments:
|
|
690
|
+
pay_income = json.dumps(payments['monthly_income'], ensure_ascii=False)
|
|
691
|
+
pay_expense = json.dumps(payments['monthly_expense'], ensure_ascii=False)
|
|
692
|
+
html += f'''
|
|
693
|
+
// Payment chart
|
|
694
|
+
new Chart(document.getElementById('paymentChart'), {{
|
|
695
|
+
type: 'bar',
|
|
696
|
+
data: {{
|
|
697
|
+
labels: {json.dumps(MONTH_LABELS, ensure_ascii=False)},
|
|
698
|
+
datasets: [
|
|
699
|
+
{{ label: '支出', data: {pay_expense}, backgroundColor: '#f8717160', borderColor: '#f87171', borderWidth: 1, borderRadius: 4 }},
|
|
700
|
+
{{ label: '收入', data: {pay_income}, backgroundColor: '#34d39960', borderColor: '#34d399', borderWidth: 1, borderRadius: 4 }},
|
|
701
|
+
],
|
|
702
|
+
}},
|
|
703
|
+
options: {{
|
|
704
|
+
...chartDefaults,
|
|
705
|
+
scales: {{
|
|
706
|
+
...chartDefaults.scales,
|
|
707
|
+
y: {{ ...chartDefaults.scales.y, title: {{ display: true, text: '元', color: '#94a3b8' }} }},
|
|
708
|
+
}},
|
|
709
|
+
}},
|
|
710
|
+
}});
|
|
711
|
+
'''
|
|
712
|
+
|
|
713
|
+
html += '''
|
|
714
|
+
</script>
|
|
715
|
+
</body>
|
|
716
|
+
</html>'''
|
|
717
|
+
return html
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
# ====== Main ======
|
|
721
|
+
|
|
722
|
+
def main():
|
|
723
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
724
|
+
import argparse
|
|
725
|
+
parser = argparse.ArgumentParser(description='年度报告生成器')
|
|
726
|
+
parser.add_argument('year', nargs='?', type=int, default=datetime.now(TZ).year,
|
|
727
|
+
help='年份(默认今年)')
|
|
728
|
+
parser.add_argument('--output', help='输出路径')
|
|
729
|
+
parser.add_argument('--share', action='store_true', help='分享模式(卡片布局)')
|
|
730
|
+
parser.add_argument('--api-key', help='DeepSeek API key')
|
|
731
|
+
parser.add_argument('--skip-ai', action='store_true', help='跳过 AI 总结')
|
|
732
|
+
args = parser.parse_args()
|
|
733
|
+
|
|
734
|
+
year = args.year
|
|
735
|
+
config = load_config()
|
|
736
|
+
api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '') or config.get('deepseekApiKey', '')
|
|
737
|
+
|
|
738
|
+
print(f'📊 正在生成 {year} 年度报告...')
|
|
739
|
+
|
|
740
|
+
# Load DB
|
|
741
|
+
nt_db = config.get('ntDbPath', '')
|
|
742
|
+
stats = {'year': year}
|
|
743
|
+
if nt_db:
|
|
744
|
+
nt_key = decrypt_lock(config.get('ntKey', ''))
|
|
745
|
+
nt_salt = config.get('ntSalt', '')
|
|
746
|
+
contact_key = decrypt_lock(config.get('contactKey', ''))
|
|
747
|
+
contact_salt = config.get('contactSalt', '')
|
|
748
|
+
msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
|
|
749
|
+
wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
|
|
750
|
+
contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
751
|
+
|
|
752
|
+
name_map = get_name_map(contact_db, contact_key, contact_salt)
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
conn = open_db(nt_db, nt_key, nt_salt)
|
|
756
|
+
chat_stats = collect_chat_stats(conn, year, name_map)
|
|
757
|
+
stats.update(chat_stats)
|
|
758
|
+
print(f' ✓ 聊天数据: {chat_stats["total_msgs"]:,} 条消息')
|
|
759
|
+
|
|
760
|
+
payments = collect_payments(conn, year)
|
|
761
|
+
if payments:
|
|
762
|
+
stats['payments'] = payments
|
|
763
|
+
print(f' ✓ 支付数据: {payments["total_count"]} 条')
|
|
764
|
+
conn.close()
|
|
765
|
+
except Exception as e:
|
|
766
|
+
print(f' [WARN] 聊天数据: {e}')
|
|
767
|
+
|
|
768
|
+
# Article stats
|
|
769
|
+
try:
|
|
770
|
+
article_stats = collect_article_stats(year)
|
|
771
|
+
stats.update(article_stats)
|
|
772
|
+
print(f' ✓ 文章数据: {article_stats["total_articles"]} 篇')
|
|
773
|
+
except Exception as e:
|
|
774
|
+
print(f' [WARN] 文章数据: {e}')
|
|
775
|
+
|
|
776
|
+
# AI summary
|
|
777
|
+
ai_summary = ''
|
|
778
|
+
if not args.skip_ai and api_key:
|
|
779
|
+
print(' ⏳ 生成 AI 总结...')
|
|
780
|
+
ai_summary = generate_ai_summary(stats, api_key)
|
|
781
|
+
if ai_summary:
|
|
782
|
+
print(' ✓ AI 总结生成完成')
|
|
783
|
+
|
|
784
|
+
# Generate HTML
|
|
785
|
+
html = generate_html(year, stats, ai_summary, share_mode=args.share)
|
|
786
|
+
out_path = args.output or os.path.join(OUTPUT_ROOT, f'annual-report-{year}.html')
|
|
787
|
+
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
|
|
788
|
+
with open(out_path, 'w', encoding='utf-8') as f:
|
|
789
|
+
f.write(html)
|
|
790
|
+
|
|
791
|
+
print(f'\n✓ 报告已生成: {out_path}')
|
|
792
|
+
print(f' 文件大小: {len(html) / 1024:.1f} KB')
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
if __name__ == '__main__':
|
|
796
|
+
main()
|