weflow-cli 1.5.0 → 1.6.1
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/ARCHITECTURE.md +105 -0
- package/CHANGELOG.md +121 -0
- package/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -21
- package/OPERATIONS.md +272 -0
- package/README.en.md +281 -0
- package/README.md +334 -149
- package/SECURITY.md +47 -0
- package/bin/weflow-cli.ts +4050 -581
- package/dist/bin/weflow-cli.js +4310 -588
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +178 -236
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +15 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +177 -19
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/keyService.d.ts +8 -0
- package/dist/src/core/keyService.d.ts.map +1 -1
- package/dist/src/core/keyService.js +127 -22
- package/dist/src/core/keyService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +59 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +201 -65
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +18 -1
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +3 -6
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/services/assistantDaemon.d.ts +19 -0
- package/dist/src/services/assistantDaemon.d.ts.map +1 -0
- package/dist/src/services/assistantDaemon.js +117 -0
- package/dist/src/services/assistantDaemon.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +36 -0
- package/dist/src/services/assistantMemory.d.ts.map +1 -0
- package/dist/src/services/assistantMemory.js +182 -0
- package/dist/src/services/assistantMemory.js.map +1 -0
- package/dist/src/services/assistantPrivacy.d.ts +25 -0
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -0
- package/dist/src/services/assistantPrivacy.js +77 -0
- package/dist/src/services/assistantPrivacy.js.map +1 -0
- package/dist/src/services/assistantRouting.d.ts +18 -0
- package/dist/src/services/assistantRouting.d.ts.map +1 -0
- package/dist/src/services/assistantRouting.js +68 -0
- package/dist/src/services/assistantRouting.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +37 -0
- package/dist/src/services/assistantService.d.ts.map +1 -0
- package/dist/src/services/assistantService.js +284 -0
- package/dist/src/services/assistantService.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +24 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -0
- package/dist/src/services/assistantTools.js +582 -0
- package/dist/src/services/assistantTools.js.map +1 -0
- package/dist/src/services/chatService.d.ts +44 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +105 -4
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +55 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +147 -6
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/evidenceService.d.ts +30 -0
- package/dist/src/services/evidenceService.d.ts.map +1 -0
- package/dist/src/services/evidenceService.js +98 -0
- package/dist/src/services/evidenceService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +13 -20
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +113 -47
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/initKeyService.d.ts +19 -0
- package/dist/src/services/initKeyService.d.ts.map +1 -0
- package/dist/src/services/initKeyService.js +80 -0
- package/dist/src/services/initKeyService.js.map +1 -0
- package/dist/src/services/messageContract.d.ts +23 -0
- package/dist/src/services/messageContract.d.ts.map +1 -0
- package/dist/src/services/messageContract.js +49 -0
- package/dist/src/services/messageContract.js.map +1 -0
- package/dist/src/services/messageQuery.d.ts +7 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -0
- package/dist/src/services/messageQuery.js +30 -0
- package/dist/src/services/messageQuery.js.map +1 -0
- package/dist/src/services/wechat-formatter.js +14 -14
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +31 -3
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/services/whitelistService.d.ts +52 -4
- package/dist/src/services/whitelistService.d.ts.map +1 -1
- package/dist/src/services/whitelistService.js +183 -17
- package/dist/src/services/whitelistService.js.map +1 -1
- package/dist/src/types.d.ts +20 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -0
- package/dist/src/utils/dateRange.js +48 -0
- package/dist/src/utils/dateRange.js.map +1 -0
- package/dist/src/utils/mcpSecurity.d.ts +5 -0
- package/dist/src/utils/mcpSecurity.d.ts.map +1 -0
- package/dist/src/utils/mcpSecurity.js +39 -0
- package/dist/src/utils/mcpSecurity.js.map +1 -0
- package/dist/src/utils/packageRoot.d.ts +2 -0
- package/dist/src/utils/packageRoot.d.ts.map +1 -0
- package/dist/src/utils/packageRoot.js +16 -0
- package/dist/src/utils/packageRoot.js.map +1 -0
- package/dist/src/utils/python.d.ts +2 -0
- package/dist/src/utils/python.d.ts.map +1 -0
- package/dist/src/utils/python.js +72 -0
- package/dist/src/utils/python.js.map +1 -0
- package/dist/src/utils/pythonProcessEnv.d.ts +3 -0
- package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -0
- package/dist/src/utils/pythonProcessEnv.js +46 -0
- package/dist/src/utils/pythonProcessEnv.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +4 -1
- package/dist/src/utils/talkerUtils.d.ts.map +1 -1
- package/dist/src/utils/talkerUtils.js +9 -9
- package/dist/src/utils/talkerUtils.js.map +1 -1
- package/dist/src/utils/wechatEmoji.d.ts +5 -0
- package/dist/src/utils/wechatEmoji.d.ts.map +1 -0
- package/dist/src/utils/wechatEmoji.js +35 -0
- package/dist/src/utils/wechatEmoji.js.map +1 -0
- package/docs/AI_INTERFACE.md +189 -0
- package/docs/BRANCHES.md +87 -0
- package/docs/DATA_CONTRACT.md +74 -0
- package/docs/DECISIONS.md +270 -0
- package/docs/DEEPSEEK_V4_FLASH/346/226/275/345/267/245/346/226/207/346/241/243.md +234 -0
- package/docs/EVIDENCE_GUIDE.md +96 -0
- package/docs/MCP.md +95 -0
- package/docs/PARTNERS.md +41 -0
- package/docs/PROJECT_STATE.md +96 -0
- package/docs/RELEASING.md +124 -0
- package/docs/ROADMAP.md +191 -0
- package/docs/SETUP.md +103 -0
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/docs/images/weflow-architecture.png +0 -0
- package/docs/images/weflow-architecture.svg +65 -0
- package/mcp-server/index.ts +169 -269
- package/package.json +81 -72
- package/requirements-3x.txt +4 -0
- package/requirements-voice.txt +15 -0
- package/requirements.txt +9 -0
- package/resources/js/marked.min.js +69 -0
- package/resources/wechat-emoji/666.png +0 -0
- package/resources/wechat-emoji/Awesome.png +0 -0
- package/resources/wechat-emoji/Concerned.png +0 -0
- package/resources/wechat-emoji/Cry.png +0 -0
- package/resources/wechat-emoji/Emm.png +0 -0
- package/resources/wechat-emoji/Facepalm.png +0 -0
- package/resources/wechat-emoji/Grin.png +0 -0
- package/resources/wechat-emoji/OK.png +0 -0
- package/resources/wechat-emoji/Respect.png +0 -0
- package/resources/wechat-emoji/Sick.png +0 -0
- package/resources/wechat-emoji/Sleep.png +0 -0
- package/resources/wechat-emoji/Smile.png +0 -0
- package/resources/wechat-emoji//344/272/262/344/272/262.png +0 -0
- package/resources/wechat-emoji//344/276/277/344/276/277.png +0 -0
- package/resources/wechat-emoji//345/201/267/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/202/262/346/205/242.png +0 -0
- package/resources/wechat-emoji//345/206/215/350/247/201.png +0 -0
- package/resources/wechat-emoji//345/207/213/350/260/242.png +0 -0
- package/resources/wechat-emoji//345/212/240/346/262/271.png +0 -0
- package/resources/wechat-emoji//345/213/276/345/274/225.png +0 -0
- package/resources/wechat-emoji//345/217/221/345/221/206.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/200/222.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/212/226.png +0 -0
- package/resources/wechat-emoji//345/217/257/346/200/234.png +0 -0
- package/resources/wechat-emoji//345/217/263/345/223/274/345/223/274.png +0 -0
- package/resources/wechat-emoji//345/217/271/346/260/224.png +0 -0
- package/resources/wechat-emoji//345/220/203/347/223/234.png +0 -0
- package/resources/wechat-emoji//345/220/210/345/215/201.png +0 -0
- package/resources/wechat-emoji//345/220/220.png +0 -0
- package/resources/wechat-emoji//345/221/262/347/211/231.png +0 -0
- package/resources/wechat-emoji//345/222/222/351/252/202.png +0 -0
- package/resources/wechat-emoji//345/222/226/345/225/241.png +0 -0
- package/resources/wechat-emoji//345/223/207.png +0 -0
- package/resources/wechat-emoji//345/225/244/351/205/222.png +0 -0
- package/resources/wechat-emoji//345/230/230.png +0 -0
- package/resources/wechat-emoji//345/230/264/345/224/207.png +0 -0
- package/resources/wechat-emoji//345/230/277/345/223/210.png +0 -0
- package/resources/wechat-emoji//345/233/247.png +0 -0
- package/resources/wechat-emoji//345/233/260.png +0 -0
- package/resources/wechat-emoji//345/235/217/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/244/247/345/223/255.png +0 -0
- package/resources/wechat-emoji//345/244/251/345/225/212.png +0 -0
- package/resources/wechat-emoji//345/244/252/351/230/263.png +0 -0
- package/resources/wechat-emoji//345/244/261/346/234/233.png +0 -0
- package/resources/wechat-emoji//345/245/270/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/245/275/347/232/204.png +0 -0
- package/resources/wechat-emoji//345/247/224/345/261/210.png +0 -0
- package/resources/wechat-emoji//345/256/263/347/276/236.png +0 -0
- package/resources/wechat-emoji//345/260/264/345/260/254.png +0 -0
- package/resources/wechat-emoji//345/272/206/347/245/235.png +0 -0
- package/resources/wechat-emoji//345/274/261.png +0 -0
- package/resources/wechat-emoji//345/274/272.png +0 -0
- package/resources/wechat-emoji//345/276/227/346/204/217.png +0 -0
- package/resources/wechat-emoji//345/276/256/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/277/203/347/242/216.png +0 -0
- package/resources/wechat-emoji//345/277/253/345/223/255/344/272/206.png +0 -0
- package/resources/wechat-emoji//346/201/220/346/203/247.png +0 -0
- package/resources/wechat-emoji//346/202/240/351/227/262.png +0 -0
- package/resources/wechat-emoji//346/203/212/346/201/220.png +0 -0
- package/resources/wechat-emoji//346/203/212/350/256/266.png +0 -0
- package/resources/wechat-emoji//346/204/211/345/277/253.png +0 -0
- package/resources/wechat-emoji//346/206/250/347/254/221.png +0 -0
- package/resources/wechat-emoji//346/211/223/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/212/223/347/213/202.png +0 -0
- package/resources/wechat-emoji//346/212/240/351/274/273.png +0 -0
- package/resources/wechat-emoji//346/212/261/346/213/263.png +0 -0
- package/resources/wechat-emoji//346/213/245/346/212/261.png +0 -0
- package/resources/wechat-emoji//346/213/263/345/244/264.png +0 -0
- package/resources/wechat-emoji//346/215/202/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/217/241/346/211/213.png +0 -0
- package/resources/wechat-emoji//346/222/207/345/230/264.png +0 -0
- package/resources/wechat-emoji//346/223/246/346/261/227.png +0 -0
- package/resources/wechat-emoji//346/225/262/346/211/223.png +0 -0
- package/resources/wechat-emoji//346/227/240/350/257/255.png +0 -0
- package/resources/wechat-emoji//346/227/272/346/237/264.png +0 -0
- package/resources/wechat-emoji//346/231/225.png +0 -0
- package/resources/wechat-emoji//346/234/210/344/272/256.png +0 -0
- package/resources/wechat-emoji//346/234/272/346/231/272.png +0 -0
- package/resources/wechat-emoji//346/261/227.png +0 -0
- package/resources/wechat-emoji//346/265/201/346/263/252.png +0 -0
- package/resources/wechat-emoji//347/202/270/345/274/271.png +0 -0
- package/resources/wechat-emoji//347/203/237/350/212/261.png +0 -0
- package/resources/wechat-emoji//347/210/206/347/253/271.png +0 -0
- package/resources/wechat-emoji//347/210/261/345/277/203.png +0 -0
- package/resources/wechat-emoji//347/214/252/345/244/264.png +0 -0
- package/resources/wechat-emoji//347/216/253/347/221/260.png +0 -0
- package/resources/wechat-emoji//347/224/237/347/227/205.png +0 -0
- package/resources/wechat-emoji//347/226/221/351/227/256.png +0 -0
- package/resources/wechat-emoji//347/231/274.png +0 -0
- package/resources/wechat-emoji//347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//347/232/261/347/234/211.png +0 -0
- package/resources/wechat-emoji//347/235/241.png +0 -0
- package/resources/wechat-emoji//347/240/264/346/266/225/344/270/272/347/254/221.png +0 -0
- package/resources/wechat-emoji//347/244/274/347/211/251.png +0 -0
- package/resources/wechat-emoji//347/244/276/344/274/232/347/244/276/344/274/232.png +0 -0
- package/resources/wechat-emoji//347/246/217.png +0 -0
- package/resources/wechat-emoji//347/254/221/350/204/270.png +0 -0
- package/resources/wechat-emoji//347/272/242/345/214/205.png +0 -0
- package/resources/wechat-emoji//347/277/273/347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//350/200/266.png +0 -0
- package/resources/wechat-emoji//350/203/234/345/210/251.png +0 -0
- package/resources/wechat-emoji//350/204/270/347/272/242.png +0 -0
- package/resources/wechat-emoji//350/211/262.png +0 -0
- package/resources/wechat-emoji//350/213/246/346/266/251.png +0 -0
- package/resources/wechat-emoji//350/217/234/345/210/200.png +0 -0
- package/resources/wechat-emoji//350/233/213/347/263/225.png +0 -0
- package/resources/wechat-emoji//350/241/260.png +0 -0
- package/resources/wechat-emoji//350/243/202/345/274/200.png +0 -0
- package/resources/wechat-emoji//350/256/251/346/210/221/347/234/213/347/234/213.png +0 -0
- package/resources/wechat-emoji//350/260/203/347/232/256.png +0 -0
- package/resources/wechat-emoji//350/267/263/350/267/263.png +0 -0
- package/resources/wechat-emoji//350/275/254/345/234/210.png +0 -0
- package/resources/wechat-emoji//351/204/231/350/247/206.png +0 -0
- package/resources/wechat-emoji//351/227/255/345/230/264.png +0 -0
- package/resources/wechat-emoji//351/230/264/351/231/251.png +0 -0
- package/resources/wechat-emoji//351/232/276/350/277/207.png +0 -0
- package/resources/wechat-emoji//351/252/267/351/253/205.png +0 -0
- package/resources/wechat-emoji//351/274/223/346/216/214.png +0 -0
- package/scripts/_batch_link_sources.py +73 -73
- package/scripts/_utils.py +29 -0
- package/scripts/annual_report.py +796 -796
- package/scripts/auto_tag.py +128 -128
- package/scripts/biz_daily.py +210 -53
- package/scripts/chat_report.py +10 -0
- package/scripts/chat_stats.py +702 -702
- package/scripts/classify_daily.py +17 -11
- package/scripts/create_reading_notes.py +370 -370
- package/scripts/daily_stats.py +132 -0
- package/scripts/enrich_backlinks.py +163 -163
- package/scripts/export_chat_html.py +1704 -107
- package/scripts/extract_todos.py +445 -404
- package/scripts/fav_server.py +119 -12
- package/scripts/fix_topics.py +111 -111
- package/scripts/generate_ai_report.py +1 -1
- package/scripts/generate_html.py +597 -277
- package/scripts/inspection_records.py +354 -0
- package/scripts/mcp_bridge.py +398 -398
- package/scripts/nt_decrypt.py +702 -48
- package/scripts/pipeline.py +27 -19
- package/scripts/promote_all.py +312 -310
- package/scripts/promote_ideas.py +299 -297
- package/scripts/rag_chat.py +194 -191
- package/scripts/semantic_search.py +435 -431
- package/scripts/sync_fav.py +201 -146
- package/scripts/sync_weread.py +192 -192
- package/scripts/vault_rag.py +140 -139
- package/scripts/vault_search.py +141 -141
- package/scripts/wechat_emoji.py +132 -0
- package/scripts/wechat_emoticon.py +433 -0
- package/scripts/wechat_image.py +258 -0
- package/scripts/wechat_voice.py +327 -0
- package/src/core/dbPathService.ts +163 -18
- package/src/core/keyService.ts +137 -25
- package/src/core/ntCore.ts +215 -62
- package/src/core/sqlcipherCore.ts +17 -1
- package/src/core/wcdbCore.ts +3 -7
- package/src/services/assistantDaemon.ts +111 -0
- package/src/services/assistantMemory.ts +193 -0
- package/src/services/assistantPrivacy.ts +87 -0
- package/src/services/assistantRouting.ts +92 -0
- package/src/services/assistantService.ts +295 -0
- package/src/services/assistantTools.ts +549 -0
- package/src/services/chatService.ts +133 -4
- package/src/services/configService.ts +194 -6
- package/src/services/evidenceService.ts +129 -0
- package/src/services/exportService.ts +113 -48
- package/src/services/initKeyService.ts +83 -0
- package/src/services/messageContract.ts +77 -0
- package/src/services/messageQuery.ts +38 -0
- package/src/services/wechat-formatter.ts +348 -348
- package/src/services/wechatMessageService.ts +25 -3
- package/src/services/wereadService.ts +308 -308
- package/src/services/whitelistService.ts +209 -22
- package/src/types.ts +20 -0
- package/src/utils/dateRange.ts +58 -0
- package/src/utils/mcpSecurity.ts +39 -0
- package/src/utils/packageRoot.ts +14 -0
- package/src/utils/python.ts +73 -0
- package/src/utils/pythonProcessEnv.ts +45 -0
- package/src/utils/talkerUtils.ts +14 -2
- package/dist/src/utils/errors.d.ts +0 -25
- package/dist/src/utils/errors.d.ts.map +0 -1
- package/dist/src/utils/errors.js +0 -43
- package/dist/src/utils/errors.js.map +0 -1
- package/dist/src/utils/pythonRunner.d.ts +0 -27
- package/dist/src/utils/pythonRunner.d.ts.map +0 -1
- package/dist/src/utils/pythonRunner.js +0 -62
- package/dist/src/utils/pythonRunner.js.map +0 -1
- package/src/utils/errors.ts +0 -42
- package/src/utils/pythonRunner.ts +0 -81
package/scripts/chat_stats.py
CHANGED
|
@@ -1,702 +1,702 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
微信个人信息消费报告 — 统计消息量、聊天对象、公众号阅读偏好、活跃时段。
|
|
4
|
-
|
|
5
|
-
用法:
|
|
6
|
-
python scripts/chat_stats.py --period week
|
|
7
|
-
python scripts/chat_stats.py --period month
|
|
8
|
-
python scripts/chat_stats.py --period month --output output/报告.md
|
|
9
|
-
|
|
10
|
-
输出: output/微信报告_<周期>.md
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import sys, os, json, re, hashlib, argparse
|
|
14
|
-
from datetime import datetime, timezone, timedelta
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from collections import Counter, defaultdict
|
|
17
|
-
|
|
18
|
-
try:
|
|
19
|
-
from sqlcipher3 import dbapi2 as sqlcipher
|
|
20
|
-
except ImportError:
|
|
21
|
-
print("请安装: pip install sqlcipher3"); 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
|
|
25
|
-
|
|
26
|
-
OUTPUT_ROOT = 'output'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
# ====== Database ======
|
|
30
|
-
|
|
31
|
-
def open_db(db_path, key_hex, salt_hex):
|
|
32
|
-
raw_key = f"x'{key_hex}{salt_hex}'"
|
|
33
|
-
conn = sqlcipher.connect(db_path)
|
|
34
|
-
c = conn.cursor()
|
|
35
|
-
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
36
|
-
c.execute("SELECT count(*) FROM sqlite_master")
|
|
37
|
-
return conn
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def get_name_map(contact_db, contact_key, contact_salt):
|
|
41
|
-
name_map = {}
|
|
42
|
-
if not contact_db or not contact_key or not os.path.exists(contact_db):
|
|
43
|
-
return name_map
|
|
44
|
-
try:
|
|
45
|
-
conn = open_db(contact_db, contact_key, contact_salt)
|
|
46
|
-
c = conn.cursor()
|
|
47
|
-
c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
|
|
48
|
-
for r in c.fetchall():
|
|
49
|
-
name_map[r[0]] = r[1]
|
|
50
|
-
conn.close()
|
|
51
|
-
except:
|
|
52
|
-
pass
|
|
53
|
-
return name_map
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def get_own_wxid(nt_db, nt_key, nt_salt):
|
|
57
|
-
"""Get own wxid — prefer config, fallback to UserInfo table."""
|
|
58
|
-
# Priority 1: config wxid (strip _xxxx suffix)
|
|
59
|
-
config = load_config()
|
|
60
|
-
wxid = config.get('wxid', '')
|
|
61
|
-
if wxid:
|
|
62
|
-
return wxid
|
|
63
|
-
# Priority 2: UserInfo table
|
|
64
|
-
try:
|
|
65
|
-
conn = open_db(nt_db, nt_key, nt_salt)
|
|
66
|
-
c = conn.cursor()
|
|
67
|
-
c.execute("SELECT user_name FROM UserInfo LIMIT 1")
|
|
68
|
-
row = c.fetchone()
|
|
69
|
-
conn.close()
|
|
70
|
-
return row[0] if row else ''
|
|
71
|
-
except:
|
|
72
|
-
return ''
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def get_biz_keys(config):
|
|
76
|
-
"""Get biz database keys."""
|
|
77
|
-
nt_db = config.get('ntDbPath', '')
|
|
78
|
-
msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
|
|
79
|
-
wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
|
|
80
|
-
contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
81
|
-
|
|
82
|
-
contact_key_enc = config.get('contactKey', '')
|
|
83
|
-
contact_salt = config.get('contactSalt', '')
|
|
84
|
-
contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
|
|
85
|
-
|
|
86
|
-
biz_db = os.path.join(msg_dir, 'biz_message_0.db')
|
|
87
|
-
biz_key_enc = config.get('bizKey', '')
|
|
88
|
-
biz_salt = config.get('bizSalt', '')
|
|
89
|
-
if biz_key_enc and biz_salt:
|
|
90
|
-
biz_key = decrypt_lock(biz_key_enc)
|
|
91
|
-
else:
|
|
92
|
-
print('[ERROR] 缺少 biz_message_0.db 密钥,请运行: python scripts/nt_decrypt.py scan --json')
|
|
93
|
-
sys.exit(1)
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
'biz_db': biz_db, 'biz_key': biz_key, 'biz_salt': biz_salt,
|
|
97
|
-
'contact_db': contact_db, 'contact_key': contact_key, 'contact_salt': contact_salt,
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# ====== Stats Collection ======
|
|
102
|
-
|
|
103
|
-
def collect_chat_stats(conn, start_ts, end_ts, name_map, own_wxid):
|
|
104
|
-
"""Collect chat message statistics."""
|
|
105
|
-
c = conn.cursor()
|
|
106
|
-
|
|
107
|
-
# Get all sessions
|
|
108
|
-
try:
|
|
109
|
-
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
110
|
-
sessions = [r[0] for r in c.fetchall()]
|
|
111
|
-
except:
|
|
112
|
-
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
113
|
-
sessions = [r[0] for r in c.fetchall()]
|
|
114
|
-
|
|
115
|
-
# Build sender_id → username map from Name2Id
|
|
116
|
-
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
117
|
-
sender_id_map = {r[0]: r[1] for r in c.fetchall()}
|
|
118
|
-
own_wxid_base = own_wxid.rsplit('_', 1)[0] if own_wxid and '_' in own_wxid else ''
|
|
119
|
-
|
|
120
|
-
total_sent = 0
|
|
121
|
-
total_recv = 0
|
|
122
|
-
talker_stats = Counter() # talker -> total messages
|
|
123
|
-
hour_dist = Counter() # hour -> message count
|
|
124
|
-
day_dist = Counter() # weekday -> message count
|
|
125
|
-
|
|
126
|
-
for talker in sessions:
|
|
127
|
-
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
128
|
-
try:
|
|
129
|
-
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
130
|
-
if c.fetchone()[0] == 0:
|
|
131
|
-
continue
|
|
132
|
-
|
|
133
|
-
c.execute(f'''
|
|
134
|
-
SELECT create_time, real_sender_id
|
|
135
|
-
FROM "{tbl}"
|
|
136
|
-
WHERE create_time >= ? AND create_time < ?
|
|
137
|
-
''', (start_ts, end_ts))
|
|
138
|
-
|
|
139
|
-
count = 0
|
|
140
|
-
for ts, sender in c.fetchall():
|
|
141
|
-
count += 1
|
|
142
|
-
dt = datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8)))
|
|
143
|
-
hour_dist[dt.hour] += 1
|
|
144
|
-
day_dist[dt.weekday()] += 1
|
|
145
|
-
|
|
146
|
-
is_self = False
|
|
147
|
-
if own_wxid:
|
|
148
|
-
# Resolve real_sender_id (int) → username via Name2Id
|
|
149
|
-
username = sender_id_map.get(sender, '') if isinstance(sender, int) else str(sender)
|
|
150
|
-
is_self = (username == own_wxid or username == own_wxid_base)
|
|
151
|
-
if not is_self and username:
|
|
152
|
-
is_self = (username == talker) # sent by self in self-chat
|
|
153
|
-
|
|
154
|
-
if is_self:
|
|
155
|
-
total_sent += 1
|
|
156
|
-
else:
|
|
157
|
-
total_recv += 1
|
|
158
|
-
|
|
159
|
-
if count > 0:
|
|
160
|
-
name = name_map.get(talker, talker)
|
|
161
|
-
talker_stats[name] += count
|
|
162
|
-
except:
|
|
163
|
-
pass
|
|
164
|
-
|
|
165
|
-
return {
|
|
166
|
-
'total_sent': total_sent,
|
|
167
|
-
'total_recv': total_recv,
|
|
168
|
-
'talker_stats': talker_stats,
|
|
169
|
-
'hour_dist': hour_dist,
|
|
170
|
-
'day_dist': day_dist,
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def collect_biz_stats(biz_db, biz_key, biz_salt, start_ts, end_ts, name_map):
|
|
175
|
-
"""Collect public account article statistics."""
|
|
176
|
-
try:
|
|
177
|
-
conn = open_db(biz_db, biz_key, biz_salt)
|
|
178
|
-
except:
|
|
179
|
-
return {'total_articles': 0, 'account_stats': Counter(), 'topic_stats': Counter()}
|
|
180
|
-
|
|
181
|
-
c = conn.cursor()
|
|
182
|
-
c.execute("SELECT user_name FROM Name2Id WHERE user_name LIKE 'gh_%'")
|
|
183
|
-
biz_users = [r[0] for r in c.fetchall()]
|
|
184
|
-
|
|
185
|
-
total_articles = 0
|
|
186
|
-
account_stats = Counter()
|
|
187
|
-
hour_dist = Counter()
|
|
188
|
-
|
|
189
|
-
for user in biz_users:
|
|
190
|
-
tbl = 'Msg_' + hashlib.md5(user.encode()).hexdigest()
|
|
191
|
-
try:
|
|
192
|
-
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
193
|
-
if c.fetchone()[0] == 0:
|
|
194
|
-
continue
|
|
195
|
-
|
|
196
|
-
c.execute(f'''
|
|
197
|
-
SELECT create_time FROM "{tbl}"
|
|
198
|
-
WHERE create_time >= ? AND create_time < ?
|
|
199
|
-
''', (start_ts, end_ts))
|
|
200
|
-
|
|
201
|
-
count = 0
|
|
202
|
-
for (ts,) in c.fetchall():
|
|
203
|
-
count += 1
|
|
204
|
-
dt = datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8)))
|
|
205
|
-
hour_dist[dt.hour] += 1
|
|
206
|
-
|
|
207
|
-
if count > 0:
|
|
208
|
-
name = name_map.get(user, user)
|
|
209
|
-
account_stats[name] += count
|
|
210
|
-
total_articles += count
|
|
211
|
-
except:
|
|
212
|
-
pass
|
|
213
|
-
|
|
214
|
-
conn.close()
|
|
215
|
-
|
|
216
|
-
# Read topic stats from existing daily reports
|
|
217
|
-
topic_stats = Counter()
|
|
218
|
-
daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
|
|
219
|
-
if daily_dir.exists():
|
|
220
|
-
for d in sorted(daily_dir.iterdir()):
|
|
221
|
-
if not d.is_dir():
|
|
222
|
-
continue
|
|
223
|
-
try:
|
|
224
|
-
dt = datetime.strptime(d.name, '%Y-%m-%d')
|
|
225
|
-
dt = dt.replace(tzinfo=timezone(timedelta(hours=8)))
|
|
226
|
-
if dt.timestamp() < start_ts or dt.timestamp() >= end_ts:
|
|
227
|
-
continue
|
|
228
|
-
except:
|
|
229
|
-
continue
|
|
230
|
-
|
|
231
|
-
readme = d / 'README.md'
|
|
232
|
-
if readme.exists():
|
|
233
|
-
content = readme.read_text(encoding='utf-8')
|
|
234
|
-
# Extract topic counts from README
|
|
235
|
-
for topic in ['AI', '学术', '新闻', '文学', '投资']:
|
|
236
|
-
match = re.search(rf'{topic}/:\s*(\d+)', content)
|
|
237
|
-
if match:
|
|
238
|
-
topic_stats[topic] += int(match.group(1))
|
|
239
|
-
|
|
240
|
-
return {
|
|
241
|
-
'total_articles': total_articles,
|
|
242
|
-
'account_stats': account_stats,
|
|
243
|
-
'topic_stats': topic_stats,
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
# ====== Report Generation ======
|
|
248
|
-
|
|
249
|
-
WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def extract_payment_totals(biz_db, biz_key, biz_salt, start_ts, end_ts):
|
|
253
|
-
"""Scan biz_message_0.db for 微信支付 transaction amounts."""
|
|
254
|
-
import hashlib as _hashlib, re as _re
|
|
255
|
-
try:
|
|
256
|
-
biz_conn = open_db(biz_db, biz_key, biz_salt)
|
|
257
|
-
except:
|
|
258
|
-
return []
|
|
259
|
-
c = biz_conn.cursor()
|
|
260
|
-
|
|
261
|
-
# Find 微信支付 account
|
|
262
|
-
c.execute("SELECT user_name FROM Name2Id WHERE user_name LIKE 'gh_%' ORDER BY user_name")
|
|
263
|
-
payee = None
|
|
264
|
-
for r in c.fetchall():
|
|
265
|
-
tbl = f"Msg_{_hashlib.md5(r[0].encode()).hexdigest()}"
|
|
266
|
-
try:
|
|
267
|
-
c.execute(f"SELECT message_content FROM \"{tbl}\" LIMIT 1")
|
|
268
|
-
row = c.fetchone()
|
|
269
|
-
if row and row[0]:
|
|
270
|
-
content = row[0]
|
|
271
|
-
if isinstance(content, bytes):
|
|
272
|
-
try:
|
|
273
|
-
import zstandard
|
|
274
|
-
dctx = zstandard.ZstdDecompressor()
|
|
275
|
-
content = dctx.decompress(content).decode('utf-8', errors='ignore')
|
|
276
|
-
except:
|
|
277
|
-
content = content.decode('utf-8', errors='ignore')[:200]
|
|
278
|
-
if '¥' in (content or ''):
|
|
279
|
-
payee = r[0]
|
|
280
|
-
break
|
|
281
|
-
except:
|
|
282
|
-
pass
|
|
283
|
-
|
|
284
|
-
if not payee:
|
|
285
|
-
biz_conn.close()
|
|
286
|
-
return []
|
|
287
|
-
|
|
288
|
-
tbl = f"Msg_{_hashlib.md5(payee.encode()).hexdigest()}"
|
|
289
|
-
try:
|
|
290
|
-
c.execute(f"SELECT create_time, message_content FROM \"{tbl}\" WHERE create_time >= ? AND create_time < ? ORDER BY create_time",
|
|
291
|
-
(start_ts, end_ts))
|
|
292
|
-
payments = []
|
|
293
|
-
for ts, content in c.fetchall():
|
|
294
|
-
if isinstance(content, bytes):
|
|
295
|
-
try:
|
|
296
|
-
import zstandard
|
|
297
|
-
dctx = zstandard.ZstdDecompressor()
|
|
298
|
-
text = dctx.decompress(content).decode('utf-8', errors='ignore')
|
|
299
|
-
except:
|
|
300
|
-
text = content.decode('utf-8', errors='ignore')[:200]
|
|
301
|
-
else:
|
|
302
|
-
text = str(content) if content else ''
|
|
303
|
-
|
|
304
|
-
# Extract amount from first <title> (duplicate in template area)
|
|
305
|
-
title_match = _re.search(r'<title><!\[CDATA\[(.*?)\]\]></title>', text)
|
|
306
|
-
if title_match:
|
|
307
|
-
title_text = title_match.group(1)
|
|
308
|
-
amt_match = _re.search(r'¥(\d+\.?\d*)', title_text)
|
|
309
|
-
if amt_match:
|
|
310
|
-
amt = float(amt_match.group(1))
|
|
311
|
-
# Get payment method from <des> (e.g. "使用建设银行储蓄卡支付¥50.00")
|
|
312
|
-
desc = ''
|
|
313
|
-
des_match = _re.search(r'<des><!\[CDATA\[(.*?)\]\]></des>', text)
|
|
314
|
-
if des_match:
|
|
315
|
-
raw = des_match.group(1)
|
|
316
|
-
raw = _re.sub(r'¥?\d+\.?\d*', '', raw) # remove amount
|
|
317
|
-
raw = _re.sub(r'\(\d+\)', '', raw) # remove card number
|
|
318
|
-
raw = _re.sub(r'\(\)', '', raw) # remove empty parens
|
|
319
|
-
raw = _re.sub(r'\s+', ' ', raw).strip() # collapse whitespace
|
|
320
|
-
desc = raw
|
|
321
|
-
if not desc:
|
|
322
|
-
desc = '—'
|
|
323
|
-
dt = datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8)))
|
|
324
|
-
payments.append({
|
|
325
|
-
'time': dt.strftime('%m-%d %H:%M'),
|
|
326
|
-
'amount': amt,
|
|
327
|
-
'desc': desc,
|
|
328
|
-
})
|
|
329
|
-
biz_conn.close()
|
|
330
|
-
return payments
|
|
331
|
-
except:
|
|
332
|
-
biz_conn.close()
|
|
333
|
-
return []
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def extract_transfers(nt_db, nt_key, nt_salt, start_ts, end_ts, name_map, own_wxid):
|
|
337
|
-
"""Scan message_0.db for 微信转账 records (type 8589934592049)."""
|
|
338
|
-
import hashlib as _hashlib, re as _re
|
|
339
|
-
try:
|
|
340
|
-
conn = open_db(nt_db, nt_key, nt_salt)
|
|
341
|
-
except:
|
|
342
|
-
return []
|
|
343
|
-
c = conn.cursor()
|
|
344
|
-
|
|
345
|
-
# Build sender_id → username map
|
|
346
|
-
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
347
|
-
sender_map = {r[0]: r[1] for r in c.fetchall()}
|
|
348
|
-
|
|
349
|
-
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
350
|
-
tables = [r[0] for r in c.fetchall()]
|
|
351
|
-
|
|
352
|
-
transfers = []
|
|
353
|
-
# First pass: collect all records into {key: {send_record, recv_record}}
|
|
354
|
-
raw = {}
|
|
355
|
-
|
|
356
|
-
for tbl in tables:
|
|
357
|
-
try:
|
|
358
|
-
c.execute(f"SELECT create_time, message_content, real_sender_id FROM \"{tbl}\" WHERE local_type = 8589934592049 AND create_time >= ? AND create_time < ?",
|
|
359
|
-
(start_ts, end_ts))
|
|
360
|
-
for ct, content, sid in c.fetchall():
|
|
361
|
-
if isinstance(content, bytes):
|
|
362
|
-
try:
|
|
363
|
-
import zstandard
|
|
364
|
-
dctx = zstandard.ZstdDecompressor()
|
|
365
|
-
text = dctx.decompress(content).decode('utf-8', errors='ignore')
|
|
366
|
-
except:
|
|
367
|
-
text = content.decode('utf-8', errors='ignore')[:500]
|
|
368
|
-
else:
|
|
369
|
-
text = str(content) if content else ''
|
|
370
|
-
|
|
371
|
-
amt_match = _re.search(r'收到转账(\d+\.?\d*)元', text)
|
|
372
|
-
if not amt_match or '转账' not in text:
|
|
373
|
-
continue
|
|
374
|
-
|
|
375
|
-
amt = float(amt_match.group(1))
|
|
376
|
-
sender_wxid = sender_map.get(sid, str(sid))
|
|
377
|
-
is_send = (sender_wxid == own_wxid or sender_wxid == own_wxid.rsplit('_', 1)[0])
|
|
378
|
-
dt = datetime.fromtimestamp(ct, tz=timezone(timedelta(hours=8)))
|
|
379
|
-
key = (dt.strftime('%m-%d %H:%M'), amt)
|
|
380
|
-
|
|
381
|
-
if key not in raw:
|
|
382
|
-
raw[key] = {}
|
|
383
|
-
if is_send:
|
|
384
|
-
raw[key]['send'] = (sender_wxid, ct)
|
|
385
|
-
else:
|
|
386
|
-
raw[key]['recv'] = (sender_wxid, ct)
|
|
387
|
-
except:
|
|
388
|
-
pass
|
|
389
|
-
|
|
390
|
-
# Second pass: merge pairs into transfer records
|
|
391
|
-
for key, pair in raw.items():
|
|
392
|
-
time_str, amt = key
|
|
393
|
-
send_time = pair.get('send', (None, 0))[1] if 'send' in pair else 0
|
|
394
|
-
recv_time = pair.get('recv', (None, 0))[1] if 'recv' in pair else 0
|
|
395
|
-
|
|
396
|
-
if 'send' in pair and 'recv' in pair:
|
|
397
|
-
# Both sides exist: order determines direction
|
|
398
|
-
if send_time < recv_time:
|
|
399
|
-
# User sent first → 发出
|
|
400
|
-
contact_wxid = pair['recv'][0]
|
|
401
|
-
contact_name = name_map.get(contact_wxid, contact_wxid)
|
|
402
|
-
transfers.append({'time': time_str, 'amount': amt, 'direction': '发出', 'contact': contact_name})
|
|
403
|
-
else:
|
|
404
|
-
# Contact sent first → 收到
|
|
405
|
-
contact_wxid = pair['recv'][0]
|
|
406
|
-
if contact_wxid != own_wxid:
|
|
407
|
-
contact_name = name_map.get(contact_wxid, contact_wxid)
|
|
408
|
-
transfers.append({'time': time_str, 'amount': amt, 'direction': '收到', 'contact': contact_name})
|
|
409
|
-
elif 'recv' in pair:
|
|
410
|
-
contact_wxid = pair['recv'][0]
|
|
411
|
-
if contact_wxid != own_wxid:
|
|
412
|
-
contact_name = name_map.get(contact_wxid, contact_wxid)
|
|
413
|
-
transfers.append({'time': time_str, 'amount': amt, 'direction': '收到', 'contact': contact_name})
|
|
414
|
-
|
|
415
|
-
conn.close()
|
|
416
|
-
return transfers
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def format_report(chat_stats, biz_stats, payments, transfers, period_label, start_date, end_date):
|
|
420
|
-
"""Generate markdown report."""
|
|
421
|
-
lines = [
|
|
422
|
-
f'# 📊 微信个人信息消费报告',
|
|
423
|
-
f'> {period_label} | {start_date} ~ {end_date}',
|
|
424
|
-
'',
|
|
425
|
-
]
|
|
426
|
-
|
|
427
|
-
# === Overview ===
|
|
428
|
-
total_msgs = chat_stats['total_sent'] + chat_stats['total_recv']
|
|
429
|
-
lines.append('## 📈 数据概览')
|
|
430
|
-
lines.append('')
|
|
431
|
-
lines.append(f'| 指标 | 数量 |')
|
|
432
|
-
lines.append(f'|------|------|')
|
|
433
|
-
lines.append(f'| 收到消息 | {chat_stats["total_recv"]:,} 条 |')
|
|
434
|
-
lines.append(f'| 发送消息 | {chat_stats["total_sent"]:,} 条 |')
|
|
435
|
-
lines.append(f'| 总消息量 | {total_msgs:,} 条 |')
|
|
436
|
-
lines.append(f'| 公众号文章 | {biz_stats["total_articles"]:,} 篇 |')
|
|
437
|
-
lines.append(f'| 活跃联系人 | {len(chat_stats["talker_stats"])} 个 |')
|
|
438
|
-
lines.append(f'| 活跃公众号 | {len(biz_stats["account_stats"])} 个 |')
|
|
439
|
-
lines.append('')
|
|
440
|
-
|
|
441
|
-
# === Top Talkers ===
|
|
442
|
-
lines.append('## 💬 Top 10 聊天对象')
|
|
443
|
-
lines.append('')
|
|
444
|
-
lines.append('| 排名 | 联系人 | 消息数 | 占比 |')
|
|
445
|
-
lines.append('|------|--------|--------|------|')
|
|
446
|
-
top_talkers = chat_stats['talker_stats'].most_common(10)
|
|
447
|
-
for i, (name, count) in enumerate(top_talkers, 1):
|
|
448
|
-
pct = f"{count / total_msgs * 100:.1f}%" if total_msgs > 0 else '0%'
|
|
449
|
-
lines.append(f'| {i} | {name} | {count:,} | {pct} |')
|
|
450
|
-
lines.append('')
|
|
451
|
-
|
|
452
|
-
# === Reading Preferences ===
|
|
453
|
-
if biz_stats['topic_stats']:
|
|
454
|
-
lines.append('## 📰 阅读偏好')
|
|
455
|
-
lines.append('')
|
|
456
|
-
total_topics = sum(biz_stats['topic_stats'].values())
|
|
457
|
-
lines.append('| 主题 | 篇数 | 占比 |')
|
|
458
|
-
lines.append('|------|------|------|')
|
|
459
|
-
topic_emoji = {'AI': '🤖', '学术': '🔬', '新闻': '📰', '文学': '📖', '投资': '💰'}
|
|
460
|
-
for topic, count in biz_stats['topic_stats'].most_common():
|
|
461
|
-
pct = f"{count / total_topics * 100:.0f}%" if total_topics > 0 else '0%'
|
|
462
|
-
emoji = topic_emoji.get(topic, '📌')
|
|
463
|
-
lines.append(f'| {emoji} {topic} | {count} | {pct} |')
|
|
464
|
-
lines.append('')
|
|
465
|
-
|
|
466
|
-
# === Top Public Accounts ===
|
|
467
|
-
if biz_stats['account_stats']:
|
|
468
|
-
lines.append('## 📱 Top 10 公众号')
|
|
469
|
-
lines.append('')
|
|
470
|
-
lines.append('| 排名 | 公众号 | 文章数 |')
|
|
471
|
-
lines.append('|------|--------|--------|')
|
|
472
|
-
for i, (name, count) in enumerate(biz_stats['account_stats'].most_common(10), 1):
|
|
473
|
-
lines.append(f'| {i} | {name} | {count} |')
|
|
474
|
-
lines.append('')
|
|
475
|
-
|
|
476
|
-
# === Activity Hours ===
|
|
477
|
-
lines.append('## ⏰ 活跃时段分布')
|
|
478
|
-
lines.append('')
|
|
479
|
-
lines.append('| 时段 | 消息数 | 热度 |')
|
|
480
|
-
lines.append('|------|--------|------|')
|
|
481
|
-
max_hour = max(chat_stats['hour_dist'].values()) if chat_stats['hour_dist'] else 1
|
|
482
|
-
for h in range(24):
|
|
483
|
-
count = chat_stats['hour_dist'].get(h, 0)
|
|
484
|
-
bar_len = int(count / max_hour * 10) if max_hour > 0 else 0
|
|
485
|
-
bar = '█' * bar_len + '░' * (10 - bar_len)
|
|
486
|
-
label = f'{h:02d}:00-{h:02d}:59'
|
|
487
|
-
lines.append(f'| {label} | {count:,} | {bar} |')
|
|
488
|
-
lines.append('')
|
|
489
|
-
|
|
490
|
-
# Most/least active hours
|
|
491
|
-
if chat_stats['hour_dist']:
|
|
492
|
-
most_active_h = chat_stats['hour_dist'].most_common(1)[0]
|
|
493
|
-
least_active_h = min(chat_stats['hour_dist'].items(), key=lambda x: x[1])
|
|
494
|
-
lines.append(f'- 🔥 最活跃:**{most_active_h[0]:02d}:00-{most_active_h[0]:02d}:59**({most_active_h[1]:,} 条)')
|
|
495
|
-
lines.append(f'- 😴 最安静:**{least_active_h[0]:02d}:00-{least_active_h[0]:02d}:59**({least_active_h[1]:,} 条)')
|
|
496
|
-
lines.append('')
|
|
497
|
-
|
|
498
|
-
# === Weekday Distribution ===
|
|
499
|
-
lines.append('## 📅 星期分布')
|
|
500
|
-
lines.append('')
|
|
501
|
-
lines.append('| 星期 | 消息数 | 热度 |')
|
|
502
|
-
lines.append('|------|--------|------|')
|
|
503
|
-
max_day = max(chat_stats['day_dist'].values()) if chat_stats['day_dist'] else 1
|
|
504
|
-
for d in range(7):
|
|
505
|
-
count = chat_stats['day_dist'].get(d, 0)
|
|
506
|
-
bar_len = int(count / max_day * 10) if max_day > 0 else 0
|
|
507
|
-
bar = '█' * bar_len + '░' * (10 - bar_len)
|
|
508
|
-
lines.append(f'| {WEEKDAY_NAMES[d]} | {count:,} | {bar} |')
|
|
509
|
-
lines.append('')
|
|
510
|
-
|
|
511
|
-
# === Fun Facts ===
|
|
512
|
-
lines.append('## 💡 趣味数据')
|
|
513
|
-
lines.append('')
|
|
514
|
-
from datetime import datetime
|
|
515
|
-
d1 = datetime.strptime(start_date, '%Y-%m-%d') if isinstance(start_date, str) else start_date
|
|
516
|
-
d2 = datetime.strptime(end_date, '%Y-%m-%d') if isinstance(end_date, str) else end_date
|
|
517
|
-
avg_per_day = total_msgs / max(1, (d2 - d1).days)
|
|
518
|
-
lines.append(f'- 📊 日均消息量:**{avg_per_day:.0f} 条**')
|
|
519
|
-
if payments:
|
|
520
|
-
total_spend = sum(p['amount'] for p in payments)
|
|
521
|
-
lines.append(f'- 💰 微信支付:**¥{total_spend:.2f}**({len(payments)} 笔交易)')
|
|
522
|
-
if chat_stats['total_sent'] > 0 and chat_stats['total_recv'] > 0:
|
|
523
|
-
ratio = chat_stats['total_sent'] / chat_stats['total_recv']
|
|
524
|
-
if ratio > 1.2:
|
|
525
|
-
lines.append(f'- 🗣️ 你是个**话痨**,发送/接收比 = {ratio:.2f}')
|
|
526
|
-
elif ratio < 0.8:
|
|
527
|
-
lines.append(f'- 👂 你是个**倾听者**,发送/接收比 = {ratio:.2f}')
|
|
528
|
-
else:
|
|
529
|
-
lines.append(f'- 🤝 沟通均衡,发送/接收比 = {ratio:.2f}')
|
|
530
|
-
if biz_stats['topic_stats']:
|
|
531
|
-
top_topic = biz_stats['topic_stats'].most_common(1)[0]
|
|
532
|
-
lines.append(f'- ❤️ 最爱看:**{top_topic[0]}**类文章({top_topic[1]} 篇)')
|
|
533
|
-
if top_talkers:
|
|
534
|
-
lines.append(f'- 🏆 最常聊天:**{top_talkers[0][0]}**({top_talkers[0][1]:,} 条)')
|
|
535
|
-
lines.append('')
|
|
536
|
-
|
|
537
|
-
# === Payment Details ===
|
|
538
|
-
if payments:
|
|
539
|
-
lines.append('## 💰 微信支付明细')
|
|
540
|
-
lines.append('')
|
|
541
|
-
total_spend = sum(p['amount'] for p in payments)
|
|
542
|
-
lines.append(f'> 共 {len(payments)} 笔 | 合计 ¥{total_spend:.2f}')
|
|
543
|
-
lines.append('')
|
|
544
|
-
lines.append('| 时间 | 金额 | 说明 |')
|
|
545
|
-
lines.append('|------|------|------|')
|
|
546
|
-
for p in sorted(payments, key=lambda x: -x['amount']):
|
|
547
|
-
desc = p.get('desc', '')
|
|
548
|
-
lines.append(f'| {p["time"]} | ¥{p["amount"]:.2f} | {desc} |')
|
|
549
|
-
lines.append('')
|
|
550
|
-
|
|
551
|
-
# === Transfer Details ===
|
|
552
|
-
if transfers:
|
|
553
|
-
lines.append('## 💸 微信转账')
|
|
554
|
-
lines.append('')
|
|
555
|
-
total_out = sum(t['amount'] for t in transfers if t['direction'] == '发出')
|
|
556
|
-
total_in = sum(t['amount'] for t in transfers if t['direction'] == '收到')
|
|
557
|
-
lines.append(f'> 共 {len(transfers)} 笔 | 收到 ¥{total_in:.2f} | 发出 ¥{total_out:.2f}')
|
|
558
|
-
lines.append('')
|
|
559
|
-
lines.append('| 日期 | 方向 | 金额 | 对方 |')
|
|
560
|
-
lines.append('|------|------|------|------|')
|
|
561
|
-
for t in sorted(transfers, key=lambda x: x['time']):
|
|
562
|
-
dir_icon = '←' if t['direction'] == '收到' else '→'
|
|
563
|
-
lines.append(f'| {t["time"]} | {dir_icon} {t["direction"]} | ¥{t["amount"]:.2f} | {t["contact"]} |')
|
|
564
|
-
lines.append('')
|
|
565
|
-
|
|
566
|
-
# === 收支总览 ===
|
|
567
|
-
total_pay = sum(p['amount'] for p in payments) if payments else 0
|
|
568
|
-
total_in = sum(t['amount'] for t in transfers if t['direction'] == '收到') if transfers else 0
|
|
569
|
-
total_out = sum(t['amount'] for t in transfers if t['direction'] == '发出') if transfers else 0
|
|
570
|
-
if total_pay or total_in or total_out:
|
|
571
|
-
lines.append('## 📊 收支总览')
|
|
572
|
-
lines.append('')
|
|
573
|
-
net = total_in - total_pay - total_out
|
|
574
|
-
lines.append(f'| 收入 | 支出 | 净额 |')
|
|
575
|
-
lines.append(f'|------|------|------|')
|
|
576
|
-
income_str = f'转账收到 ¥{total_in:.2f}' if total_in else '—'
|
|
577
|
-
expense_parts = []
|
|
578
|
-
if total_pay:
|
|
579
|
-
expense_parts.append(f'消费 ¥{total_pay:.2f}')
|
|
580
|
-
if total_out:
|
|
581
|
-
expense_parts.append(f'转出 ¥{total_out:.2f}')
|
|
582
|
-
expense_str = ' + '.join(expense_parts) if expense_parts else '—'
|
|
583
|
-
sign = '+' if net >= 0 else ''
|
|
584
|
-
lines.append(f'| {income_str} | {expense_str} | {sign}¥{net:.2f} |')
|
|
585
|
-
lines.append('')
|
|
586
|
-
|
|
587
|
-
return '\n'.join(lines)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
# ====== Main ======
|
|
591
|
-
|
|
592
|
-
def main():
|
|
593
|
-
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
594
|
-
parser = argparse.ArgumentParser(description='微信个人信息消费报告')
|
|
595
|
-
parser.add_argument('--period', choices=['week', 'month'], default='week', help='报告周期(默认 week)')
|
|
596
|
-
parser.add_argument('--month', help='指定月份 YYYY-MM(覆盖 --period)')
|
|
597
|
-
parser.add_argument('--output', help='输出文件路径')
|
|
598
|
-
args = parser.parse_args()
|
|
599
|
-
|
|
600
|
-
tz = timezone(timedelta(hours=8))
|
|
601
|
-
now = datetime.now(tz)
|
|
602
|
-
|
|
603
|
-
if args.month:
|
|
604
|
-
# Specified month
|
|
605
|
-
y, m = args.month.split('-')
|
|
606
|
-
start_date = datetime(int(y), int(m), 1, tzinfo=tz)
|
|
607
|
-
if int(m) == 12:
|
|
608
|
-
end_date = datetime(int(y) + 1, 1, 1, tzinfo=tz)
|
|
609
|
-
else:
|
|
610
|
-
end_date = datetime(int(y), int(m) + 1, 1, tzinfo=tz)
|
|
611
|
-
period_label = f'月报 — {args.month}'
|
|
612
|
-
elif args.period == 'week':
|
|
613
|
-
days_since_monday = now.weekday()
|
|
614
|
-
start_date = (now - timedelta(days=days_since_monday)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
615
|
-
end_date = now
|
|
616
|
-
period_label = f'周报 — {start_date.strftime("%Y-%m-%d")} ~ {end_date.strftime("%Y-%m-%d")}'
|
|
617
|
-
else:
|
|
618
|
-
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
619
|
-
end_date = now
|
|
620
|
-
period_label = f'月报 — {now.strftime("%Y-%m")}'
|
|
621
|
-
|
|
622
|
-
start_ts = int(start_date.timestamp())
|
|
623
|
-
end_ts = int(end_date.timestamp())
|
|
624
|
-
|
|
625
|
-
print(f'=== 微信个人信息消费报告 ===')
|
|
626
|
-
print(f'周期: {period_label}')
|
|
627
|
-
|
|
628
|
-
# Load config
|
|
629
|
-
config = load_config()
|
|
630
|
-
nt_db = config.get('ntDbPath', '')
|
|
631
|
-
if not nt_db:
|
|
632
|
-
print('[ERROR] 未找到数据库路径,请先运行 weflow-cli init')
|
|
633
|
-
sys.exit(1)
|
|
634
|
-
|
|
635
|
-
nt_key = decrypt_lock(config.get('ntKey', ''))
|
|
636
|
-
nt_salt = config.get('ntSalt', '')
|
|
637
|
-
|
|
638
|
-
# Get name map and own wxid
|
|
639
|
-
keys = get_biz_keys(config)
|
|
640
|
-
name_map = get_name_map(keys['contact_db'], keys['contact_key'], keys['contact_salt'])
|
|
641
|
-
own_wxid = get_own_wxid(nt_db, nt_key, nt_salt)
|
|
642
|
-
print(f'联系人映射: {len(name_map)} |
|
|
643
|
-
|
|
644
|
-
# Collect chat stats
|
|
645
|
-
print(f'\n统计聊天消息...')
|
|
646
|
-
try:
|
|
647
|
-
conn = open_db(nt_db, nt_key, nt_salt)
|
|
648
|
-
except Exception as e:
|
|
649
|
-
print(f'[ERROR] 打开数据库失败: {e}')
|
|
650
|
-
sys.exit(1)
|
|
651
|
-
|
|
652
|
-
chat_stats = collect_chat_stats(conn, start_ts, end_ts, name_map, own_wxid)
|
|
653
|
-
conn.close()
|
|
654
|
-
print(f' 消息: 发送 {chat_stats["total_sent"]:,} / 接收 {chat_stats["total_recv"]:,}')
|
|
655
|
-
print(f' 联系人: {len(chat_stats["talker_stats"])} 个')
|
|
656
|
-
|
|
657
|
-
# Collect biz stats
|
|
658
|
-
print(f'\n统计公众号文章...')
|
|
659
|
-
biz_stats = collect_biz_stats(
|
|
660
|
-
keys['biz_db'], keys['biz_key'], keys['biz_salt'],
|
|
661
|
-
start_ts, end_ts, name_map
|
|
662
|
-
)
|
|
663
|
-
print(f' 文章: {biz_stats["total_articles"]:,} 篇')
|
|
664
|
-
print(f' 公众号: {len(biz_stats["account_stats"])} 个')
|
|
665
|
-
|
|
666
|
-
# Collect payment data from biz database
|
|
667
|
-
payments = extract_payment_totals(
|
|
668
|
-
keys['biz_db'], keys['biz_key'], keys['biz_salt'],
|
|
669
|
-
start_ts, end_ts
|
|
670
|
-
)
|
|
671
|
-
if payments:
|
|
672
|
-
total = sum(p['amount'] for p in payments)
|
|
673
|
-
print(f' 微信支付: {len(payments)} 笔, ¥{total:.2f}')
|
|
674
|
-
|
|
675
|
-
# Collect transfer data
|
|
676
|
-
print(f'\n统计转账...')
|
|
677
|
-
transfers = extract_transfers(nt_db, nt_key, nt_salt, start_ts, end_ts, name_map, own_wxid)
|
|
678
|
-
total_in = sum(t['amount'] for t in transfers if t['direction'] == '收到')
|
|
679
|
-
total_out = sum(t['amount'] for t in transfers if t['direction'] == '发出')
|
|
680
|
-
if transfers:
|
|
681
|
-
print(f' 转账: {len(transfers)} 笔 | 收到 ¥{total_in:.2f} | 发出 ¥{total_out:.2f}')
|
|
682
|
-
|
|
683
|
-
# Generate report
|
|
684
|
-
print(f'\n生成报告...')
|
|
685
|
-
report = format_report(
|
|
686
|
-
chat_stats, biz_stats, payments, transfers, period_label,
|
|
687
|
-
start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')
|
|
688
|
-
)
|
|
689
|
-
|
|
690
|
-
# Save
|
|
691
|
-
suffix = args.month.replace('-', '') if args.month else args.period
|
|
692
|
-
output_path = args.output or os.path.join(OUTPUT_ROOT, f'微信报告_{suffix}.md')
|
|
693
|
-
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
|
|
694
|
-
with open(output_path, 'w', encoding='utf-8') as f:
|
|
695
|
-
f.write(report)
|
|
696
|
-
|
|
697
|
-
print(f'\n✓ 已保存到: {output_path}')
|
|
698
|
-
print(report[:500] + '...')
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if __name__ == '__main__':
|
|
702
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
微信个人信息消费报告 — 统计消息量、聊天对象、公众号阅读偏好、活跃时段。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python scripts/chat_stats.py --period week
|
|
7
|
+
python scripts/chat_stats.py --period month
|
|
8
|
+
python scripts/chat_stats.py --period month --output output/报告.md
|
|
9
|
+
|
|
10
|
+
输出: output/微信报告_<周期>.md
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys, os, json, re, hashlib, argparse
|
|
14
|
+
from datetime import datetime, timezone, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from collections import Counter, defaultdict
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from sqlcipher3 import dbapi2 as sqlcipher
|
|
20
|
+
except ImportError:
|
|
21
|
+
print("请安装: pip install sqlcipher3"); 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
|
|
25
|
+
|
|
26
|
+
OUTPUT_ROOT = 'output'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ====== Database ======
|
|
30
|
+
|
|
31
|
+
def open_db(db_path, key_hex, salt_hex):
|
|
32
|
+
raw_key = f"x'{key_hex}{salt_hex}'"
|
|
33
|
+
conn = sqlcipher.connect(db_path)
|
|
34
|
+
c = conn.cursor()
|
|
35
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
36
|
+
c.execute("SELECT count(*) FROM sqlite_master")
|
|
37
|
+
return conn
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_name_map(contact_db, contact_key, contact_salt):
|
|
41
|
+
name_map = {}
|
|
42
|
+
if not contact_db or not contact_key or not os.path.exists(contact_db):
|
|
43
|
+
return name_map
|
|
44
|
+
try:
|
|
45
|
+
conn = open_db(contact_db, contact_key, contact_salt)
|
|
46
|
+
c = conn.cursor()
|
|
47
|
+
c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
|
|
48
|
+
for r in c.fetchall():
|
|
49
|
+
name_map[r[0]] = r[1]
|
|
50
|
+
conn.close()
|
|
51
|
+
except:
|
|
52
|
+
pass
|
|
53
|
+
return name_map
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_own_wxid(nt_db, nt_key, nt_salt):
|
|
57
|
+
"""Get own wxid — prefer config, fallback to UserInfo table."""
|
|
58
|
+
# Priority 1: config wxid (strip _xxxx suffix)
|
|
59
|
+
config = load_config()
|
|
60
|
+
wxid = config.get('wxid', '')
|
|
61
|
+
if wxid:
|
|
62
|
+
return wxid
|
|
63
|
+
# Priority 2: UserInfo table
|
|
64
|
+
try:
|
|
65
|
+
conn = open_db(nt_db, nt_key, nt_salt)
|
|
66
|
+
c = conn.cursor()
|
|
67
|
+
c.execute("SELECT user_name FROM UserInfo LIMIT 1")
|
|
68
|
+
row = c.fetchone()
|
|
69
|
+
conn.close()
|
|
70
|
+
return row[0] if row else ''
|
|
71
|
+
except:
|
|
72
|
+
return ''
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_biz_keys(config):
|
|
76
|
+
"""Get biz database keys."""
|
|
77
|
+
nt_db = config.get('ntDbPath', '')
|
|
78
|
+
msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
|
|
79
|
+
wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
|
|
80
|
+
contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
81
|
+
|
|
82
|
+
contact_key_enc = config.get('contactKey', '')
|
|
83
|
+
contact_salt = config.get('contactSalt', '')
|
|
84
|
+
contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
|
|
85
|
+
|
|
86
|
+
biz_db = os.path.join(msg_dir, 'biz_message_0.db')
|
|
87
|
+
biz_key_enc = config.get('bizKey', '')
|
|
88
|
+
biz_salt = config.get('bizSalt', '')
|
|
89
|
+
if biz_key_enc and biz_salt:
|
|
90
|
+
biz_key = decrypt_lock(biz_key_enc)
|
|
91
|
+
else:
|
|
92
|
+
print('[ERROR] 缺少 biz_message_0.db 密钥,请运行: python scripts/nt_decrypt.py scan --json')
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
'biz_db': biz_db, 'biz_key': biz_key, 'biz_salt': biz_salt,
|
|
97
|
+
'contact_db': contact_db, 'contact_key': contact_key, 'contact_salt': contact_salt,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ====== Stats Collection ======
|
|
102
|
+
|
|
103
|
+
def collect_chat_stats(conn, start_ts, end_ts, name_map, own_wxid):
|
|
104
|
+
"""Collect chat message statistics."""
|
|
105
|
+
c = conn.cursor()
|
|
106
|
+
|
|
107
|
+
# Get all sessions
|
|
108
|
+
try:
|
|
109
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
110
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
111
|
+
except:
|
|
112
|
+
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
113
|
+
sessions = [r[0] for r in c.fetchall()]
|
|
114
|
+
|
|
115
|
+
# Build sender_id → username map from Name2Id
|
|
116
|
+
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
117
|
+
sender_id_map = {r[0]: r[1] for r in c.fetchall()}
|
|
118
|
+
own_wxid_base = own_wxid.rsplit('_', 1)[0] if own_wxid and '_' in own_wxid else ''
|
|
119
|
+
|
|
120
|
+
total_sent = 0
|
|
121
|
+
total_recv = 0
|
|
122
|
+
talker_stats = Counter() # talker -> total messages
|
|
123
|
+
hour_dist = Counter() # hour -> message count
|
|
124
|
+
day_dist = Counter() # weekday -> message count
|
|
125
|
+
|
|
126
|
+
for talker in sessions:
|
|
127
|
+
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
128
|
+
try:
|
|
129
|
+
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
130
|
+
if c.fetchone()[0] == 0:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
c.execute(f'''
|
|
134
|
+
SELECT create_time, real_sender_id
|
|
135
|
+
FROM "{tbl}"
|
|
136
|
+
WHERE create_time >= ? AND create_time < ?
|
|
137
|
+
''', (start_ts, end_ts))
|
|
138
|
+
|
|
139
|
+
count = 0
|
|
140
|
+
for ts, sender in c.fetchall():
|
|
141
|
+
count += 1
|
|
142
|
+
dt = datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8)))
|
|
143
|
+
hour_dist[dt.hour] += 1
|
|
144
|
+
day_dist[dt.weekday()] += 1
|
|
145
|
+
|
|
146
|
+
is_self = False
|
|
147
|
+
if own_wxid:
|
|
148
|
+
# Resolve real_sender_id (int) → username via Name2Id
|
|
149
|
+
username = sender_id_map.get(sender, '') if isinstance(sender, int) else str(sender)
|
|
150
|
+
is_self = (username == own_wxid or username == own_wxid_base)
|
|
151
|
+
if not is_self and username:
|
|
152
|
+
is_self = (username == talker) # sent by self in self-chat
|
|
153
|
+
|
|
154
|
+
if is_self:
|
|
155
|
+
total_sent += 1
|
|
156
|
+
else:
|
|
157
|
+
total_recv += 1
|
|
158
|
+
|
|
159
|
+
if count > 0:
|
|
160
|
+
name = name_map.get(talker, talker)
|
|
161
|
+
talker_stats[name] += count
|
|
162
|
+
except:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
'total_sent': total_sent,
|
|
167
|
+
'total_recv': total_recv,
|
|
168
|
+
'talker_stats': talker_stats,
|
|
169
|
+
'hour_dist': hour_dist,
|
|
170
|
+
'day_dist': day_dist,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def collect_biz_stats(biz_db, biz_key, biz_salt, start_ts, end_ts, name_map):
|
|
175
|
+
"""Collect public account article statistics."""
|
|
176
|
+
try:
|
|
177
|
+
conn = open_db(biz_db, biz_key, biz_salt)
|
|
178
|
+
except:
|
|
179
|
+
return {'total_articles': 0, 'account_stats': Counter(), 'topic_stats': Counter()}
|
|
180
|
+
|
|
181
|
+
c = conn.cursor()
|
|
182
|
+
c.execute("SELECT user_name FROM Name2Id WHERE user_name LIKE 'gh_%'")
|
|
183
|
+
biz_users = [r[0] for r in c.fetchall()]
|
|
184
|
+
|
|
185
|
+
total_articles = 0
|
|
186
|
+
account_stats = Counter()
|
|
187
|
+
hour_dist = Counter()
|
|
188
|
+
|
|
189
|
+
for user in biz_users:
|
|
190
|
+
tbl = 'Msg_' + hashlib.md5(user.encode()).hexdigest()
|
|
191
|
+
try:
|
|
192
|
+
c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
|
|
193
|
+
if c.fetchone()[0] == 0:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
c.execute(f'''
|
|
197
|
+
SELECT create_time FROM "{tbl}"
|
|
198
|
+
WHERE create_time >= ? AND create_time < ?
|
|
199
|
+
''', (start_ts, end_ts))
|
|
200
|
+
|
|
201
|
+
count = 0
|
|
202
|
+
for (ts,) in c.fetchall():
|
|
203
|
+
count += 1
|
|
204
|
+
dt = datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8)))
|
|
205
|
+
hour_dist[dt.hour] += 1
|
|
206
|
+
|
|
207
|
+
if count > 0:
|
|
208
|
+
name = name_map.get(user, user)
|
|
209
|
+
account_stats[name] += count
|
|
210
|
+
total_articles += count
|
|
211
|
+
except:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
conn.close()
|
|
215
|
+
|
|
216
|
+
# Read topic stats from existing daily reports
|
|
217
|
+
topic_stats = Counter()
|
|
218
|
+
daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
|
|
219
|
+
if daily_dir.exists():
|
|
220
|
+
for d in sorted(daily_dir.iterdir()):
|
|
221
|
+
if not d.is_dir():
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
dt = datetime.strptime(d.name, '%Y-%m-%d')
|
|
225
|
+
dt = dt.replace(tzinfo=timezone(timedelta(hours=8)))
|
|
226
|
+
if dt.timestamp() < start_ts or dt.timestamp() >= end_ts:
|
|
227
|
+
continue
|
|
228
|
+
except:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
readme = d / 'README.md'
|
|
232
|
+
if readme.exists():
|
|
233
|
+
content = readme.read_text(encoding='utf-8')
|
|
234
|
+
# Extract topic counts from README
|
|
235
|
+
for topic in ['AI', '学术', '新闻', '文学', '投资']:
|
|
236
|
+
match = re.search(rf'{topic}/:\s*(\d+)', content)
|
|
237
|
+
if match:
|
|
238
|
+
topic_stats[topic] += int(match.group(1))
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
'total_articles': total_articles,
|
|
242
|
+
'account_stats': account_stats,
|
|
243
|
+
'topic_stats': topic_stats,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ====== Report Generation ======
|
|
248
|
+
|
|
249
|
+
WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def extract_payment_totals(biz_db, biz_key, biz_salt, start_ts, end_ts):
|
|
253
|
+
"""Scan biz_message_0.db for 微信支付 transaction amounts."""
|
|
254
|
+
import hashlib as _hashlib, re as _re
|
|
255
|
+
try:
|
|
256
|
+
biz_conn = open_db(biz_db, biz_key, biz_salt)
|
|
257
|
+
except:
|
|
258
|
+
return []
|
|
259
|
+
c = biz_conn.cursor()
|
|
260
|
+
|
|
261
|
+
# Find 微信支付 account
|
|
262
|
+
c.execute("SELECT user_name FROM Name2Id WHERE user_name LIKE 'gh_%' ORDER BY user_name")
|
|
263
|
+
payee = None
|
|
264
|
+
for r in c.fetchall():
|
|
265
|
+
tbl = f"Msg_{_hashlib.md5(r[0].encode()).hexdigest()}"
|
|
266
|
+
try:
|
|
267
|
+
c.execute(f"SELECT message_content FROM \"{tbl}\" LIMIT 1")
|
|
268
|
+
row = c.fetchone()
|
|
269
|
+
if row and row[0]:
|
|
270
|
+
content = row[0]
|
|
271
|
+
if isinstance(content, bytes):
|
|
272
|
+
try:
|
|
273
|
+
import zstandard
|
|
274
|
+
dctx = zstandard.ZstdDecompressor()
|
|
275
|
+
content = dctx.decompress(content).decode('utf-8', errors='ignore')
|
|
276
|
+
except:
|
|
277
|
+
content = content.decode('utf-8', errors='ignore')[:200]
|
|
278
|
+
if '¥' in (content or ''):
|
|
279
|
+
payee = r[0]
|
|
280
|
+
break
|
|
281
|
+
except:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
if not payee:
|
|
285
|
+
biz_conn.close()
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
tbl = f"Msg_{_hashlib.md5(payee.encode()).hexdigest()}"
|
|
289
|
+
try:
|
|
290
|
+
c.execute(f"SELECT create_time, message_content FROM \"{tbl}\" WHERE create_time >= ? AND create_time < ? ORDER BY create_time",
|
|
291
|
+
(start_ts, end_ts))
|
|
292
|
+
payments = []
|
|
293
|
+
for ts, content in c.fetchall():
|
|
294
|
+
if isinstance(content, bytes):
|
|
295
|
+
try:
|
|
296
|
+
import zstandard
|
|
297
|
+
dctx = zstandard.ZstdDecompressor()
|
|
298
|
+
text = dctx.decompress(content).decode('utf-8', errors='ignore')
|
|
299
|
+
except:
|
|
300
|
+
text = content.decode('utf-8', errors='ignore')[:200]
|
|
301
|
+
else:
|
|
302
|
+
text = str(content) if content else ''
|
|
303
|
+
|
|
304
|
+
# Extract amount from first <title> (duplicate in template area)
|
|
305
|
+
title_match = _re.search(r'<title><!\[CDATA\[(.*?)\]\]></title>', text)
|
|
306
|
+
if title_match:
|
|
307
|
+
title_text = title_match.group(1)
|
|
308
|
+
amt_match = _re.search(r'¥(\d+\.?\d*)', title_text)
|
|
309
|
+
if amt_match:
|
|
310
|
+
amt = float(amt_match.group(1))
|
|
311
|
+
# Get payment method from <des> (e.g. "使用建设银行储蓄卡支付¥50.00")
|
|
312
|
+
desc = ''
|
|
313
|
+
des_match = _re.search(r'<des><!\[CDATA\[(.*?)\]\]></des>', text)
|
|
314
|
+
if des_match:
|
|
315
|
+
raw = des_match.group(1)
|
|
316
|
+
raw = _re.sub(r'¥?\d+\.?\d*', '', raw) # remove amount
|
|
317
|
+
raw = _re.sub(r'\(\d+\)', '', raw) # remove card number
|
|
318
|
+
raw = _re.sub(r'\(\)', '', raw) # remove empty parens
|
|
319
|
+
raw = _re.sub(r'\s+', ' ', raw).strip() # collapse whitespace
|
|
320
|
+
desc = raw
|
|
321
|
+
if not desc:
|
|
322
|
+
desc = '—'
|
|
323
|
+
dt = datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8)))
|
|
324
|
+
payments.append({
|
|
325
|
+
'time': dt.strftime('%m-%d %H:%M'),
|
|
326
|
+
'amount': amt,
|
|
327
|
+
'desc': desc,
|
|
328
|
+
})
|
|
329
|
+
biz_conn.close()
|
|
330
|
+
return payments
|
|
331
|
+
except:
|
|
332
|
+
biz_conn.close()
|
|
333
|
+
return []
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def extract_transfers(nt_db, nt_key, nt_salt, start_ts, end_ts, name_map, own_wxid):
|
|
337
|
+
"""Scan message_0.db for 微信转账 records (type 8589934592049)."""
|
|
338
|
+
import hashlib as _hashlib, re as _re
|
|
339
|
+
try:
|
|
340
|
+
conn = open_db(nt_db, nt_key, nt_salt)
|
|
341
|
+
except:
|
|
342
|
+
return []
|
|
343
|
+
c = conn.cursor()
|
|
344
|
+
|
|
345
|
+
# Build sender_id → username map
|
|
346
|
+
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
347
|
+
sender_map = {r[0]: r[1] for r in c.fetchall()}
|
|
348
|
+
|
|
349
|
+
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
350
|
+
tables = [r[0] for r in c.fetchall()]
|
|
351
|
+
|
|
352
|
+
transfers = []
|
|
353
|
+
# First pass: collect all records into {key: {send_record, recv_record}}
|
|
354
|
+
raw = {}
|
|
355
|
+
|
|
356
|
+
for tbl in tables:
|
|
357
|
+
try:
|
|
358
|
+
c.execute(f"SELECT create_time, message_content, real_sender_id FROM \"{tbl}\" WHERE local_type = 8589934592049 AND create_time >= ? AND create_time < ?",
|
|
359
|
+
(start_ts, end_ts))
|
|
360
|
+
for ct, content, sid in c.fetchall():
|
|
361
|
+
if isinstance(content, bytes):
|
|
362
|
+
try:
|
|
363
|
+
import zstandard
|
|
364
|
+
dctx = zstandard.ZstdDecompressor()
|
|
365
|
+
text = dctx.decompress(content).decode('utf-8', errors='ignore')
|
|
366
|
+
except:
|
|
367
|
+
text = content.decode('utf-8', errors='ignore')[:500]
|
|
368
|
+
else:
|
|
369
|
+
text = str(content) if content else ''
|
|
370
|
+
|
|
371
|
+
amt_match = _re.search(r'收到转账(\d+\.?\d*)元', text)
|
|
372
|
+
if not amt_match or '转账' not in text:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
amt = float(amt_match.group(1))
|
|
376
|
+
sender_wxid = sender_map.get(sid, str(sid))
|
|
377
|
+
is_send = (sender_wxid == own_wxid or sender_wxid == own_wxid.rsplit('_', 1)[0])
|
|
378
|
+
dt = datetime.fromtimestamp(ct, tz=timezone(timedelta(hours=8)))
|
|
379
|
+
key = (dt.strftime('%m-%d %H:%M'), amt)
|
|
380
|
+
|
|
381
|
+
if key not in raw:
|
|
382
|
+
raw[key] = {}
|
|
383
|
+
if is_send:
|
|
384
|
+
raw[key]['send'] = (sender_wxid, ct)
|
|
385
|
+
else:
|
|
386
|
+
raw[key]['recv'] = (sender_wxid, ct)
|
|
387
|
+
except:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
# Second pass: merge pairs into transfer records
|
|
391
|
+
for key, pair in raw.items():
|
|
392
|
+
time_str, amt = key
|
|
393
|
+
send_time = pair.get('send', (None, 0))[1] if 'send' in pair else 0
|
|
394
|
+
recv_time = pair.get('recv', (None, 0))[1] if 'recv' in pair else 0
|
|
395
|
+
|
|
396
|
+
if 'send' in pair and 'recv' in pair:
|
|
397
|
+
# Both sides exist: order determines direction
|
|
398
|
+
if send_time < recv_time:
|
|
399
|
+
# User sent first → 发出
|
|
400
|
+
contact_wxid = pair['recv'][0]
|
|
401
|
+
contact_name = name_map.get(contact_wxid, contact_wxid)
|
|
402
|
+
transfers.append({'time': time_str, 'amount': amt, 'direction': '发出', 'contact': contact_name})
|
|
403
|
+
else:
|
|
404
|
+
# Contact sent first → 收到
|
|
405
|
+
contact_wxid = pair['recv'][0]
|
|
406
|
+
if contact_wxid != own_wxid:
|
|
407
|
+
contact_name = name_map.get(contact_wxid, contact_wxid)
|
|
408
|
+
transfers.append({'time': time_str, 'amount': amt, 'direction': '收到', 'contact': contact_name})
|
|
409
|
+
elif 'recv' in pair:
|
|
410
|
+
contact_wxid = pair['recv'][0]
|
|
411
|
+
if contact_wxid != own_wxid:
|
|
412
|
+
contact_name = name_map.get(contact_wxid, contact_wxid)
|
|
413
|
+
transfers.append({'time': time_str, 'amount': amt, 'direction': '收到', 'contact': contact_name})
|
|
414
|
+
|
|
415
|
+
conn.close()
|
|
416
|
+
return transfers
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def format_report(chat_stats, biz_stats, payments, transfers, period_label, start_date, end_date):
|
|
420
|
+
"""Generate markdown report."""
|
|
421
|
+
lines = [
|
|
422
|
+
f'# 📊 微信个人信息消费报告',
|
|
423
|
+
f'> {period_label} | {start_date} ~ {end_date}',
|
|
424
|
+
'',
|
|
425
|
+
]
|
|
426
|
+
|
|
427
|
+
# === Overview ===
|
|
428
|
+
total_msgs = chat_stats['total_sent'] + chat_stats['total_recv']
|
|
429
|
+
lines.append('## 📈 数据概览')
|
|
430
|
+
lines.append('')
|
|
431
|
+
lines.append(f'| 指标 | 数量 |')
|
|
432
|
+
lines.append(f'|------|------|')
|
|
433
|
+
lines.append(f'| 收到消息 | {chat_stats["total_recv"]:,} 条 |')
|
|
434
|
+
lines.append(f'| 发送消息 | {chat_stats["total_sent"]:,} 条 |')
|
|
435
|
+
lines.append(f'| 总消息量 | {total_msgs:,} 条 |')
|
|
436
|
+
lines.append(f'| 公众号文章 | {biz_stats["total_articles"]:,} 篇 |')
|
|
437
|
+
lines.append(f'| 活跃联系人 | {len(chat_stats["talker_stats"])} 个 |')
|
|
438
|
+
lines.append(f'| 活跃公众号 | {len(biz_stats["account_stats"])} 个 |')
|
|
439
|
+
lines.append('')
|
|
440
|
+
|
|
441
|
+
# === Top Talkers ===
|
|
442
|
+
lines.append('## 💬 Top 10 聊天对象')
|
|
443
|
+
lines.append('')
|
|
444
|
+
lines.append('| 排名 | 联系人 | 消息数 | 占比 |')
|
|
445
|
+
lines.append('|------|--------|--------|------|')
|
|
446
|
+
top_talkers = chat_stats['talker_stats'].most_common(10)
|
|
447
|
+
for i, (name, count) in enumerate(top_talkers, 1):
|
|
448
|
+
pct = f"{count / total_msgs * 100:.1f}%" if total_msgs > 0 else '0%'
|
|
449
|
+
lines.append(f'| {i} | {name} | {count:,} | {pct} |')
|
|
450
|
+
lines.append('')
|
|
451
|
+
|
|
452
|
+
# === Reading Preferences ===
|
|
453
|
+
if biz_stats['topic_stats']:
|
|
454
|
+
lines.append('## 📰 阅读偏好')
|
|
455
|
+
lines.append('')
|
|
456
|
+
total_topics = sum(biz_stats['topic_stats'].values())
|
|
457
|
+
lines.append('| 主题 | 篇数 | 占比 |')
|
|
458
|
+
lines.append('|------|------|------|')
|
|
459
|
+
topic_emoji = {'AI': '🤖', '学术': '🔬', '新闻': '📰', '文学': '📖', '投资': '💰'}
|
|
460
|
+
for topic, count in biz_stats['topic_stats'].most_common():
|
|
461
|
+
pct = f"{count / total_topics * 100:.0f}%" if total_topics > 0 else '0%'
|
|
462
|
+
emoji = topic_emoji.get(topic, '📌')
|
|
463
|
+
lines.append(f'| {emoji} {topic} | {count} | {pct} |')
|
|
464
|
+
lines.append('')
|
|
465
|
+
|
|
466
|
+
# === Top Public Accounts ===
|
|
467
|
+
if biz_stats['account_stats']:
|
|
468
|
+
lines.append('## 📱 Top 10 公众号')
|
|
469
|
+
lines.append('')
|
|
470
|
+
lines.append('| 排名 | 公众号 | 文章数 |')
|
|
471
|
+
lines.append('|------|--------|--------|')
|
|
472
|
+
for i, (name, count) in enumerate(biz_stats['account_stats'].most_common(10), 1):
|
|
473
|
+
lines.append(f'| {i} | {name} | {count} |')
|
|
474
|
+
lines.append('')
|
|
475
|
+
|
|
476
|
+
# === Activity Hours ===
|
|
477
|
+
lines.append('## ⏰ 活跃时段分布')
|
|
478
|
+
lines.append('')
|
|
479
|
+
lines.append('| 时段 | 消息数 | 热度 |')
|
|
480
|
+
lines.append('|------|--------|------|')
|
|
481
|
+
max_hour = max(chat_stats['hour_dist'].values()) if chat_stats['hour_dist'] else 1
|
|
482
|
+
for h in range(24):
|
|
483
|
+
count = chat_stats['hour_dist'].get(h, 0)
|
|
484
|
+
bar_len = int(count / max_hour * 10) if max_hour > 0 else 0
|
|
485
|
+
bar = '█' * bar_len + '░' * (10 - bar_len)
|
|
486
|
+
label = f'{h:02d}:00-{h:02d}:59'
|
|
487
|
+
lines.append(f'| {label} | {count:,} | {bar} |')
|
|
488
|
+
lines.append('')
|
|
489
|
+
|
|
490
|
+
# Most/least active hours
|
|
491
|
+
if chat_stats['hour_dist']:
|
|
492
|
+
most_active_h = chat_stats['hour_dist'].most_common(1)[0]
|
|
493
|
+
least_active_h = min(chat_stats['hour_dist'].items(), key=lambda x: x[1])
|
|
494
|
+
lines.append(f'- 🔥 最活跃:**{most_active_h[0]:02d}:00-{most_active_h[0]:02d}:59**({most_active_h[1]:,} 条)')
|
|
495
|
+
lines.append(f'- 😴 最安静:**{least_active_h[0]:02d}:00-{least_active_h[0]:02d}:59**({least_active_h[1]:,} 条)')
|
|
496
|
+
lines.append('')
|
|
497
|
+
|
|
498
|
+
# === Weekday Distribution ===
|
|
499
|
+
lines.append('## 📅 星期分布')
|
|
500
|
+
lines.append('')
|
|
501
|
+
lines.append('| 星期 | 消息数 | 热度 |')
|
|
502
|
+
lines.append('|------|--------|------|')
|
|
503
|
+
max_day = max(chat_stats['day_dist'].values()) if chat_stats['day_dist'] else 1
|
|
504
|
+
for d in range(7):
|
|
505
|
+
count = chat_stats['day_dist'].get(d, 0)
|
|
506
|
+
bar_len = int(count / max_day * 10) if max_day > 0 else 0
|
|
507
|
+
bar = '█' * bar_len + '░' * (10 - bar_len)
|
|
508
|
+
lines.append(f'| {WEEKDAY_NAMES[d]} | {count:,} | {bar} |')
|
|
509
|
+
lines.append('')
|
|
510
|
+
|
|
511
|
+
# === Fun Facts ===
|
|
512
|
+
lines.append('## 💡 趣味数据')
|
|
513
|
+
lines.append('')
|
|
514
|
+
from datetime import datetime
|
|
515
|
+
d1 = datetime.strptime(start_date, '%Y-%m-%d') if isinstance(start_date, str) else start_date
|
|
516
|
+
d2 = datetime.strptime(end_date, '%Y-%m-%d') if isinstance(end_date, str) else end_date
|
|
517
|
+
avg_per_day = total_msgs / max(1, (d2 - d1).days)
|
|
518
|
+
lines.append(f'- 📊 日均消息量:**{avg_per_day:.0f} 条**')
|
|
519
|
+
if payments:
|
|
520
|
+
total_spend = sum(p['amount'] for p in payments)
|
|
521
|
+
lines.append(f'- 💰 微信支付:**¥{total_spend:.2f}**({len(payments)} 笔交易)')
|
|
522
|
+
if chat_stats['total_sent'] > 0 and chat_stats['total_recv'] > 0:
|
|
523
|
+
ratio = chat_stats['total_sent'] / chat_stats['total_recv']
|
|
524
|
+
if ratio > 1.2:
|
|
525
|
+
lines.append(f'- 🗣️ 你是个**话痨**,发送/接收比 = {ratio:.2f}')
|
|
526
|
+
elif ratio < 0.8:
|
|
527
|
+
lines.append(f'- 👂 你是个**倾听者**,发送/接收比 = {ratio:.2f}')
|
|
528
|
+
else:
|
|
529
|
+
lines.append(f'- 🤝 沟通均衡,发送/接收比 = {ratio:.2f}')
|
|
530
|
+
if biz_stats['topic_stats']:
|
|
531
|
+
top_topic = biz_stats['topic_stats'].most_common(1)[0]
|
|
532
|
+
lines.append(f'- ❤️ 最爱看:**{top_topic[0]}**类文章({top_topic[1]} 篇)')
|
|
533
|
+
if top_talkers:
|
|
534
|
+
lines.append(f'- 🏆 最常聊天:**{top_talkers[0][0]}**({top_talkers[0][1]:,} 条)')
|
|
535
|
+
lines.append('')
|
|
536
|
+
|
|
537
|
+
# === Payment Details ===
|
|
538
|
+
if payments:
|
|
539
|
+
lines.append('## 💰 微信支付明细')
|
|
540
|
+
lines.append('')
|
|
541
|
+
total_spend = sum(p['amount'] for p in payments)
|
|
542
|
+
lines.append(f'> 共 {len(payments)} 笔 | 合计 ¥{total_spend:.2f}')
|
|
543
|
+
lines.append('')
|
|
544
|
+
lines.append('| 时间 | 金额 | 说明 |')
|
|
545
|
+
lines.append('|------|------|------|')
|
|
546
|
+
for p in sorted(payments, key=lambda x: -x['amount']):
|
|
547
|
+
desc = p.get('desc', '')
|
|
548
|
+
lines.append(f'| {p["time"]} | ¥{p["amount"]:.2f} | {desc} |')
|
|
549
|
+
lines.append('')
|
|
550
|
+
|
|
551
|
+
# === Transfer Details ===
|
|
552
|
+
if transfers:
|
|
553
|
+
lines.append('## 💸 微信转账')
|
|
554
|
+
lines.append('')
|
|
555
|
+
total_out = sum(t['amount'] for t in transfers if t['direction'] == '发出')
|
|
556
|
+
total_in = sum(t['amount'] for t in transfers if t['direction'] == '收到')
|
|
557
|
+
lines.append(f'> 共 {len(transfers)} 笔 | 收到 ¥{total_in:.2f} | 发出 ¥{total_out:.2f}')
|
|
558
|
+
lines.append('')
|
|
559
|
+
lines.append('| 日期 | 方向 | 金额 | 对方 |')
|
|
560
|
+
lines.append('|------|------|------|------|')
|
|
561
|
+
for t in sorted(transfers, key=lambda x: x['time']):
|
|
562
|
+
dir_icon = '←' if t['direction'] == '收到' else '→'
|
|
563
|
+
lines.append(f'| {t["time"]} | {dir_icon} {t["direction"]} | ¥{t["amount"]:.2f} | {t["contact"]} |')
|
|
564
|
+
lines.append('')
|
|
565
|
+
|
|
566
|
+
# === 收支总览 ===
|
|
567
|
+
total_pay = sum(p['amount'] for p in payments) if payments else 0
|
|
568
|
+
total_in = sum(t['amount'] for t in transfers if t['direction'] == '收到') if transfers else 0
|
|
569
|
+
total_out = sum(t['amount'] for t in transfers if t['direction'] == '发出') if transfers else 0
|
|
570
|
+
if total_pay or total_in or total_out:
|
|
571
|
+
lines.append('## 📊 收支总览')
|
|
572
|
+
lines.append('')
|
|
573
|
+
net = total_in - total_pay - total_out
|
|
574
|
+
lines.append(f'| 收入 | 支出 | 净额 |')
|
|
575
|
+
lines.append(f'|------|------|------|')
|
|
576
|
+
income_str = f'转账收到 ¥{total_in:.2f}' if total_in else '—'
|
|
577
|
+
expense_parts = []
|
|
578
|
+
if total_pay:
|
|
579
|
+
expense_parts.append(f'消费 ¥{total_pay:.2f}')
|
|
580
|
+
if total_out:
|
|
581
|
+
expense_parts.append(f'转出 ¥{total_out:.2f}')
|
|
582
|
+
expense_str = ' + '.join(expense_parts) if expense_parts else '—'
|
|
583
|
+
sign = '+' if net >= 0 else ''
|
|
584
|
+
lines.append(f'| {income_str} | {expense_str} | {sign}¥{net:.2f} |')
|
|
585
|
+
lines.append('')
|
|
586
|
+
|
|
587
|
+
return '\n'.join(lines)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# ====== Main ======
|
|
591
|
+
|
|
592
|
+
def main():
|
|
593
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
594
|
+
parser = argparse.ArgumentParser(description='微信个人信息消费报告')
|
|
595
|
+
parser.add_argument('--period', choices=['week', 'month'], default='week', help='报告周期(默认 week)')
|
|
596
|
+
parser.add_argument('--month', help='指定月份 YYYY-MM(覆盖 --period)')
|
|
597
|
+
parser.add_argument('--output', help='输出文件路径')
|
|
598
|
+
args = parser.parse_args()
|
|
599
|
+
|
|
600
|
+
tz = timezone(timedelta(hours=8))
|
|
601
|
+
now = datetime.now(tz)
|
|
602
|
+
|
|
603
|
+
if args.month:
|
|
604
|
+
# Specified month
|
|
605
|
+
y, m = args.month.split('-')
|
|
606
|
+
start_date = datetime(int(y), int(m), 1, tzinfo=tz)
|
|
607
|
+
if int(m) == 12:
|
|
608
|
+
end_date = datetime(int(y) + 1, 1, 1, tzinfo=tz)
|
|
609
|
+
else:
|
|
610
|
+
end_date = datetime(int(y), int(m) + 1, 1, tzinfo=tz)
|
|
611
|
+
period_label = f'月报 — {args.month}'
|
|
612
|
+
elif args.period == 'week':
|
|
613
|
+
days_since_monday = now.weekday()
|
|
614
|
+
start_date = (now - timedelta(days=days_since_monday)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
615
|
+
end_date = now
|
|
616
|
+
period_label = f'周报 — {start_date.strftime("%Y-%m-%d")} ~ {end_date.strftime("%Y-%m-%d")}'
|
|
617
|
+
else:
|
|
618
|
+
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
619
|
+
end_date = now
|
|
620
|
+
period_label = f'月报 — {now.strftime("%Y-%m")}'
|
|
621
|
+
|
|
622
|
+
start_ts = int(start_date.timestamp())
|
|
623
|
+
end_ts = int(end_date.timestamp())
|
|
624
|
+
|
|
625
|
+
print(f'=== 微信个人信息消费报告 ===')
|
|
626
|
+
print(f'周期: {period_label}')
|
|
627
|
+
|
|
628
|
+
# Load config
|
|
629
|
+
config = load_config()
|
|
630
|
+
nt_db = config.get('ntDbPath', '')
|
|
631
|
+
if not nt_db:
|
|
632
|
+
print('[ERROR] 未找到数据库路径,请先运行 weflow-cli init')
|
|
633
|
+
sys.exit(1)
|
|
634
|
+
|
|
635
|
+
nt_key = decrypt_lock(config.get('ntKey', ''))
|
|
636
|
+
nt_salt = config.get('ntSalt', '')
|
|
637
|
+
|
|
638
|
+
# Get name map and own wxid
|
|
639
|
+
keys = get_biz_keys(config)
|
|
640
|
+
name_map = get_name_map(keys['contact_db'], keys['contact_key'], keys['contact_salt'])
|
|
641
|
+
own_wxid = get_own_wxid(nt_db, nt_key, nt_salt)
|
|
642
|
+
print(f'联系人映射: {len(name_map)} | 本人账号: 已识别,未显示')
|
|
643
|
+
|
|
644
|
+
# Collect chat stats
|
|
645
|
+
print(f'\n统计聊天消息...')
|
|
646
|
+
try:
|
|
647
|
+
conn = open_db(nt_db, nt_key, nt_salt)
|
|
648
|
+
except Exception as e:
|
|
649
|
+
print(f'[ERROR] 打开数据库失败: {e}')
|
|
650
|
+
sys.exit(1)
|
|
651
|
+
|
|
652
|
+
chat_stats = collect_chat_stats(conn, start_ts, end_ts, name_map, own_wxid)
|
|
653
|
+
conn.close()
|
|
654
|
+
print(f' 消息: 发送 {chat_stats["total_sent"]:,} / 接收 {chat_stats["total_recv"]:,}')
|
|
655
|
+
print(f' 联系人: {len(chat_stats["talker_stats"])} 个')
|
|
656
|
+
|
|
657
|
+
# Collect biz stats
|
|
658
|
+
print(f'\n统计公众号文章...')
|
|
659
|
+
biz_stats = collect_biz_stats(
|
|
660
|
+
keys['biz_db'], keys['biz_key'], keys['biz_salt'],
|
|
661
|
+
start_ts, end_ts, name_map
|
|
662
|
+
)
|
|
663
|
+
print(f' 文章: {biz_stats["total_articles"]:,} 篇')
|
|
664
|
+
print(f' 公众号: {len(biz_stats["account_stats"])} 个')
|
|
665
|
+
|
|
666
|
+
# Collect payment data from biz database
|
|
667
|
+
payments = extract_payment_totals(
|
|
668
|
+
keys['biz_db'], keys['biz_key'], keys['biz_salt'],
|
|
669
|
+
start_ts, end_ts
|
|
670
|
+
)
|
|
671
|
+
if payments:
|
|
672
|
+
total = sum(p['amount'] for p in payments)
|
|
673
|
+
print(f' 微信支付: {len(payments)} 笔, ¥{total:.2f}')
|
|
674
|
+
|
|
675
|
+
# Collect transfer data
|
|
676
|
+
print(f'\n统计转账...')
|
|
677
|
+
transfers = extract_transfers(nt_db, nt_key, nt_salt, start_ts, end_ts, name_map, own_wxid)
|
|
678
|
+
total_in = sum(t['amount'] for t in transfers if t['direction'] == '收到')
|
|
679
|
+
total_out = sum(t['amount'] for t in transfers if t['direction'] == '发出')
|
|
680
|
+
if transfers:
|
|
681
|
+
print(f' 转账: {len(transfers)} 笔 | 收到 ¥{total_in:.2f} | 发出 ¥{total_out:.2f}')
|
|
682
|
+
|
|
683
|
+
# Generate report
|
|
684
|
+
print(f'\n生成报告...')
|
|
685
|
+
report = format_report(
|
|
686
|
+
chat_stats, biz_stats, payments, transfers, period_label,
|
|
687
|
+
start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Save
|
|
691
|
+
suffix = args.month.replace('-', '') if args.month else args.period
|
|
692
|
+
output_path = args.output or os.path.join(OUTPUT_ROOT, f'微信报告_{suffix}.md')
|
|
693
|
+
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
|
|
694
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
695
|
+
f.write(report)
|
|
696
|
+
|
|
697
|
+
print(f'\n✓ 已保存到: {output_path}')
|
|
698
|
+
print(report[:500] + '...')
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
if __name__ == '__main__':
|
|
702
|
+
main()
|