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.
Files changed (332) hide show
  1. package/ARCHITECTURE.md +105 -0
  2. package/CHANGELOG.md +121 -0
  3. package/CONTRIBUTING.md +45 -0
  4. package/LICENSE +21 -21
  5. package/OPERATIONS.md +272 -0
  6. package/README.en.md +281 -0
  7. package/README.md +334 -149
  8. package/SECURITY.md +47 -0
  9. package/bin/weflow-cli.ts +4050 -581
  10. package/dist/bin/weflow-cli.js +4310 -588
  11. package/dist/bin/weflow-cli.js.map +1 -1
  12. package/dist/mcp-server/index.js +178 -236
  13. package/dist/mcp-server/index.js.map +1 -1
  14. package/dist/src/core/dbPathService.d.ts +15 -1
  15. package/dist/src/core/dbPathService.d.ts.map +1 -1
  16. package/dist/src/core/dbPathService.js +177 -19
  17. package/dist/src/core/dbPathService.js.map +1 -1
  18. package/dist/src/core/keyService.d.ts +8 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -1
  20. package/dist/src/core/keyService.js +127 -22
  21. package/dist/src/core/keyService.js.map +1 -1
  22. package/dist/src/core/ntCore.d.ts +59 -1
  23. package/dist/src/core/ntCore.d.ts.map +1 -1
  24. package/dist/src/core/ntCore.js +201 -65
  25. package/dist/src/core/ntCore.js.map +1 -1
  26. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  27. package/dist/src/core/sqlcipherCore.js +18 -1
  28. package/dist/src/core/sqlcipherCore.js.map +1 -1
  29. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  30. package/dist/src/core/wcdbCore.js +3 -6
  31. package/dist/src/core/wcdbCore.js.map +1 -1
  32. package/dist/src/services/assistantDaemon.d.ts +19 -0
  33. package/dist/src/services/assistantDaemon.d.ts.map +1 -0
  34. package/dist/src/services/assistantDaemon.js +117 -0
  35. package/dist/src/services/assistantDaemon.js.map +1 -0
  36. package/dist/src/services/assistantMemory.d.ts +36 -0
  37. package/dist/src/services/assistantMemory.d.ts.map +1 -0
  38. package/dist/src/services/assistantMemory.js +182 -0
  39. package/dist/src/services/assistantMemory.js.map +1 -0
  40. package/dist/src/services/assistantPrivacy.d.ts +25 -0
  41. package/dist/src/services/assistantPrivacy.d.ts.map +1 -0
  42. package/dist/src/services/assistantPrivacy.js +77 -0
  43. package/dist/src/services/assistantPrivacy.js.map +1 -0
  44. package/dist/src/services/assistantRouting.d.ts +18 -0
  45. package/dist/src/services/assistantRouting.d.ts.map +1 -0
  46. package/dist/src/services/assistantRouting.js +68 -0
  47. package/dist/src/services/assistantRouting.js.map +1 -0
  48. package/dist/src/services/assistantService.d.ts +37 -0
  49. package/dist/src/services/assistantService.d.ts.map +1 -0
  50. package/dist/src/services/assistantService.js +284 -0
  51. package/dist/src/services/assistantService.js.map +1 -0
  52. package/dist/src/services/assistantTools.d.ts +24 -0
  53. package/dist/src/services/assistantTools.d.ts.map +1 -0
  54. package/dist/src/services/assistantTools.js +582 -0
  55. package/dist/src/services/assistantTools.js.map +1 -0
  56. package/dist/src/services/chatService.d.ts +44 -0
  57. package/dist/src/services/chatService.d.ts.map +1 -1
  58. package/dist/src/services/chatService.js +105 -4
  59. package/dist/src/services/chatService.js.map +1 -1
  60. package/dist/src/services/configService.d.ts +55 -0
  61. package/dist/src/services/configService.d.ts.map +1 -1
  62. package/dist/src/services/configService.js +147 -6
  63. package/dist/src/services/configService.js.map +1 -1
  64. package/dist/src/services/evidenceService.d.ts +30 -0
  65. package/dist/src/services/evidenceService.d.ts.map +1 -0
  66. package/dist/src/services/evidenceService.js +98 -0
  67. package/dist/src/services/evidenceService.js.map +1 -0
  68. package/dist/src/services/exportService.d.ts +13 -20
  69. package/dist/src/services/exportService.d.ts.map +1 -1
  70. package/dist/src/services/exportService.js +113 -47
  71. package/dist/src/services/exportService.js.map +1 -1
  72. package/dist/src/services/initKeyService.d.ts +19 -0
  73. package/dist/src/services/initKeyService.d.ts.map +1 -0
  74. package/dist/src/services/initKeyService.js +80 -0
  75. package/dist/src/services/initKeyService.js.map +1 -0
  76. package/dist/src/services/messageContract.d.ts +23 -0
  77. package/dist/src/services/messageContract.d.ts.map +1 -0
  78. package/dist/src/services/messageContract.js +49 -0
  79. package/dist/src/services/messageContract.js.map +1 -0
  80. package/dist/src/services/messageQuery.d.ts +7 -0
  81. package/dist/src/services/messageQuery.d.ts.map +1 -0
  82. package/dist/src/services/messageQuery.js +30 -0
  83. package/dist/src/services/messageQuery.js.map +1 -0
  84. package/dist/src/services/wechat-formatter.js +14 -14
  85. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  86. package/dist/src/services/wechatMessageService.js +31 -3
  87. package/dist/src/services/wechatMessageService.js.map +1 -1
  88. package/dist/src/services/whitelistService.d.ts +52 -4
  89. package/dist/src/services/whitelistService.d.ts.map +1 -1
  90. package/dist/src/services/whitelistService.js +183 -17
  91. package/dist/src/services/whitelistService.js.map +1 -1
  92. package/dist/src/types.d.ts +20 -0
  93. package/dist/src/types.d.ts.map +1 -1
  94. package/dist/src/utils/dateRange.d.ts +15 -0
  95. package/dist/src/utils/dateRange.d.ts.map +1 -0
  96. package/dist/src/utils/dateRange.js +48 -0
  97. package/dist/src/utils/dateRange.js.map +1 -0
  98. package/dist/src/utils/mcpSecurity.d.ts +5 -0
  99. package/dist/src/utils/mcpSecurity.d.ts.map +1 -0
  100. package/dist/src/utils/mcpSecurity.js +39 -0
  101. package/dist/src/utils/mcpSecurity.js.map +1 -0
  102. package/dist/src/utils/packageRoot.d.ts +2 -0
  103. package/dist/src/utils/packageRoot.d.ts.map +1 -0
  104. package/dist/src/utils/packageRoot.js +16 -0
  105. package/dist/src/utils/packageRoot.js.map +1 -0
  106. package/dist/src/utils/python.d.ts +2 -0
  107. package/dist/src/utils/python.d.ts.map +1 -0
  108. package/dist/src/utils/python.js +72 -0
  109. package/dist/src/utils/python.js.map +1 -0
  110. package/dist/src/utils/pythonProcessEnv.d.ts +3 -0
  111. package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -0
  112. package/dist/src/utils/pythonProcessEnv.js +46 -0
  113. package/dist/src/utils/pythonProcessEnv.js.map +1 -0
  114. package/dist/src/utils/talkerUtils.d.ts +4 -1
  115. package/dist/src/utils/talkerUtils.d.ts.map +1 -1
  116. package/dist/src/utils/talkerUtils.js +9 -9
  117. package/dist/src/utils/talkerUtils.js.map +1 -1
  118. package/dist/src/utils/wechatEmoji.d.ts +5 -0
  119. package/dist/src/utils/wechatEmoji.d.ts.map +1 -0
  120. package/dist/src/utils/wechatEmoji.js +35 -0
  121. package/dist/src/utils/wechatEmoji.js.map +1 -0
  122. package/docs/AI_INTERFACE.md +189 -0
  123. package/docs/BRANCHES.md +87 -0
  124. package/docs/DATA_CONTRACT.md +74 -0
  125. package/docs/DECISIONS.md +270 -0
  126. package/docs/DEEPSEEK_V4_FLASH/346/226/275/345/267/245/346/226/207/346/241/243.md +234 -0
  127. package/docs/EVIDENCE_GUIDE.md +96 -0
  128. package/docs/MCP.md +95 -0
  129. package/docs/PARTNERS.md +41 -0
  130. package/docs/PROJECT_STATE.md +96 -0
  131. package/docs/RELEASING.md +124 -0
  132. package/docs/ROADMAP.md +191 -0
  133. package/docs/SETUP.md +103 -0
  134. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  135. package/docs/images/weflow-architecture.png +0 -0
  136. package/docs/images/weflow-architecture.svg +65 -0
  137. package/mcp-server/index.ts +169 -269
  138. package/package.json +81 -72
  139. package/requirements-3x.txt +4 -0
  140. package/requirements-voice.txt +15 -0
  141. package/requirements.txt +9 -0
  142. package/resources/js/marked.min.js +69 -0
  143. package/resources/wechat-emoji/666.png +0 -0
  144. package/resources/wechat-emoji/Awesome.png +0 -0
  145. package/resources/wechat-emoji/Concerned.png +0 -0
  146. package/resources/wechat-emoji/Cry.png +0 -0
  147. package/resources/wechat-emoji/Emm.png +0 -0
  148. package/resources/wechat-emoji/Facepalm.png +0 -0
  149. package/resources/wechat-emoji/Grin.png +0 -0
  150. package/resources/wechat-emoji/OK.png +0 -0
  151. package/resources/wechat-emoji/Respect.png +0 -0
  152. package/resources/wechat-emoji/Sick.png +0 -0
  153. package/resources/wechat-emoji/Sleep.png +0 -0
  154. package/resources/wechat-emoji/Smile.png +0 -0
  155. package/resources/wechat-emoji//344/272/262/344/272/262.png +0 -0
  156. package/resources/wechat-emoji//344/276/277/344/276/277.png +0 -0
  157. package/resources/wechat-emoji//345/201/267/347/254/221.png +0 -0
  158. package/resources/wechat-emoji//345/202/262/346/205/242.png +0 -0
  159. package/resources/wechat-emoji//345/206/215/350/247/201.png +0 -0
  160. package/resources/wechat-emoji//345/207/213/350/260/242.png +0 -0
  161. package/resources/wechat-emoji//345/212/240/346/262/271.png +0 -0
  162. package/resources/wechat-emoji//345/213/276/345/274/225.png +0 -0
  163. package/resources/wechat-emoji//345/217/221/345/221/206.png +0 -0
  164. package/resources/wechat-emoji//345/217/221/346/200/222.png +0 -0
  165. package/resources/wechat-emoji//345/217/221/346/212/226.png +0 -0
  166. package/resources/wechat-emoji//345/217/257/346/200/234.png +0 -0
  167. package/resources/wechat-emoji//345/217/263/345/223/274/345/223/274.png +0 -0
  168. package/resources/wechat-emoji//345/217/271/346/260/224.png +0 -0
  169. package/resources/wechat-emoji//345/220/203/347/223/234.png +0 -0
  170. package/resources/wechat-emoji//345/220/210/345/215/201.png +0 -0
  171. package/resources/wechat-emoji//345/220/220.png +0 -0
  172. package/resources/wechat-emoji//345/221/262/347/211/231.png +0 -0
  173. package/resources/wechat-emoji//345/222/222/351/252/202.png +0 -0
  174. package/resources/wechat-emoji//345/222/226/345/225/241.png +0 -0
  175. package/resources/wechat-emoji//345/223/207.png +0 -0
  176. package/resources/wechat-emoji//345/225/244/351/205/222.png +0 -0
  177. package/resources/wechat-emoji//345/230/230.png +0 -0
  178. package/resources/wechat-emoji//345/230/264/345/224/207.png +0 -0
  179. package/resources/wechat-emoji//345/230/277/345/223/210.png +0 -0
  180. package/resources/wechat-emoji//345/233/247.png +0 -0
  181. package/resources/wechat-emoji//345/233/260.png +0 -0
  182. package/resources/wechat-emoji//345/235/217/347/254/221.png +0 -0
  183. package/resources/wechat-emoji//345/244/247/345/223/255.png +0 -0
  184. package/resources/wechat-emoji//345/244/251/345/225/212.png +0 -0
  185. package/resources/wechat-emoji//345/244/252/351/230/263.png +0 -0
  186. package/resources/wechat-emoji//345/244/261/346/234/233.png +0 -0
  187. package/resources/wechat-emoji//345/245/270/347/254/221.png +0 -0
  188. package/resources/wechat-emoji//345/245/275/347/232/204.png +0 -0
  189. package/resources/wechat-emoji//345/247/224/345/261/210.png +0 -0
  190. package/resources/wechat-emoji//345/256/263/347/276/236.png +0 -0
  191. package/resources/wechat-emoji//345/260/264/345/260/254.png +0 -0
  192. package/resources/wechat-emoji//345/272/206/347/245/235.png +0 -0
  193. package/resources/wechat-emoji//345/274/261.png +0 -0
  194. package/resources/wechat-emoji//345/274/272.png +0 -0
  195. package/resources/wechat-emoji//345/276/227/346/204/217.png +0 -0
  196. package/resources/wechat-emoji//345/276/256/347/254/221.png +0 -0
  197. package/resources/wechat-emoji//345/277/203/347/242/216.png +0 -0
  198. package/resources/wechat-emoji//345/277/253/345/223/255/344/272/206.png +0 -0
  199. package/resources/wechat-emoji//346/201/220/346/203/247.png +0 -0
  200. package/resources/wechat-emoji//346/202/240/351/227/262.png +0 -0
  201. package/resources/wechat-emoji//346/203/212/346/201/220.png +0 -0
  202. package/resources/wechat-emoji//346/203/212/350/256/266.png +0 -0
  203. package/resources/wechat-emoji//346/204/211/345/277/253.png +0 -0
  204. package/resources/wechat-emoji//346/206/250/347/254/221.png +0 -0
  205. package/resources/wechat-emoji//346/211/223/350/204/270.png +0 -0
  206. package/resources/wechat-emoji//346/212/223/347/213/202.png +0 -0
  207. package/resources/wechat-emoji//346/212/240/351/274/273.png +0 -0
  208. package/resources/wechat-emoji//346/212/261/346/213/263.png +0 -0
  209. package/resources/wechat-emoji//346/213/245/346/212/261.png +0 -0
  210. package/resources/wechat-emoji//346/213/263/345/244/264.png +0 -0
  211. package/resources/wechat-emoji//346/215/202/350/204/270.png +0 -0
  212. package/resources/wechat-emoji//346/217/241/346/211/213.png +0 -0
  213. package/resources/wechat-emoji//346/222/207/345/230/264.png +0 -0
  214. package/resources/wechat-emoji//346/223/246/346/261/227.png +0 -0
  215. package/resources/wechat-emoji//346/225/262/346/211/223.png +0 -0
  216. package/resources/wechat-emoji//346/227/240/350/257/255.png +0 -0
  217. package/resources/wechat-emoji//346/227/272/346/237/264.png +0 -0
  218. package/resources/wechat-emoji//346/231/225.png +0 -0
  219. package/resources/wechat-emoji//346/234/210/344/272/256.png +0 -0
  220. package/resources/wechat-emoji//346/234/272/346/231/272.png +0 -0
  221. package/resources/wechat-emoji//346/261/227.png +0 -0
  222. package/resources/wechat-emoji//346/265/201/346/263/252.png +0 -0
  223. package/resources/wechat-emoji//347/202/270/345/274/271.png +0 -0
  224. package/resources/wechat-emoji//347/203/237/350/212/261.png +0 -0
  225. package/resources/wechat-emoji//347/210/206/347/253/271.png +0 -0
  226. package/resources/wechat-emoji//347/210/261/345/277/203.png +0 -0
  227. package/resources/wechat-emoji//347/214/252/345/244/264.png +0 -0
  228. package/resources/wechat-emoji//347/216/253/347/221/260.png +0 -0
  229. package/resources/wechat-emoji//347/224/237/347/227/205.png +0 -0
  230. package/resources/wechat-emoji//347/226/221/351/227/256.png +0 -0
  231. package/resources/wechat-emoji//347/231/274.png +0 -0
  232. package/resources/wechat-emoji//347/231/275/347/234/274.png +0 -0
  233. package/resources/wechat-emoji//347/232/261/347/234/211.png +0 -0
  234. package/resources/wechat-emoji//347/235/241.png +0 -0
  235. package/resources/wechat-emoji//347/240/264/346/266/225/344/270/272/347/254/221.png +0 -0
  236. package/resources/wechat-emoji//347/244/274/347/211/251.png +0 -0
  237. package/resources/wechat-emoji//347/244/276/344/274/232/347/244/276/344/274/232.png +0 -0
  238. package/resources/wechat-emoji//347/246/217.png +0 -0
  239. package/resources/wechat-emoji//347/254/221/350/204/270.png +0 -0
  240. package/resources/wechat-emoji//347/272/242/345/214/205.png +0 -0
  241. package/resources/wechat-emoji//347/277/273/347/231/275/347/234/274.png +0 -0
  242. package/resources/wechat-emoji//350/200/266.png +0 -0
  243. package/resources/wechat-emoji//350/203/234/345/210/251.png +0 -0
  244. package/resources/wechat-emoji//350/204/270/347/272/242.png +0 -0
  245. package/resources/wechat-emoji//350/211/262.png +0 -0
  246. package/resources/wechat-emoji//350/213/246/346/266/251.png +0 -0
  247. package/resources/wechat-emoji//350/217/234/345/210/200.png +0 -0
  248. package/resources/wechat-emoji//350/233/213/347/263/225.png +0 -0
  249. package/resources/wechat-emoji//350/241/260.png +0 -0
  250. package/resources/wechat-emoji//350/243/202/345/274/200.png +0 -0
  251. package/resources/wechat-emoji//350/256/251/346/210/221/347/234/213/347/234/213.png +0 -0
  252. package/resources/wechat-emoji//350/260/203/347/232/256.png +0 -0
  253. package/resources/wechat-emoji//350/267/263/350/267/263.png +0 -0
  254. package/resources/wechat-emoji//350/275/254/345/234/210.png +0 -0
  255. package/resources/wechat-emoji//351/204/231/350/247/206.png +0 -0
  256. package/resources/wechat-emoji//351/227/255/345/230/264.png +0 -0
  257. package/resources/wechat-emoji//351/230/264/351/231/251.png +0 -0
  258. package/resources/wechat-emoji//351/232/276/350/277/207.png +0 -0
  259. package/resources/wechat-emoji//351/252/267/351/253/205.png +0 -0
  260. package/resources/wechat-emoji//351/274/223/346/216/214.png +0 -0
  261. package/scripts/_batch_link_sources.py +73 -73
  262. package/scripts/_utils.py +29 -0
  263. package/scripts/annual_report.py +796 -796
  264. package/scripts/auto_tag.py +128 -128
  265. package/scripts/biz_daily.py +210 -53
  266. package/scripts/chat_report.py +10 -0
  267. package/scripts/chat_stats.py +702 -702
  268. package/scripts/classify_daily.py +17 -11
  269. package/scripts/create_reading_notes.py +370 -370
  270. package/scripts/daily_stats.py +132 -0
  271. package/scripts/enrich_backlinks.py +163 -163
  272. package/scripts/export_chat_html.py +1704 -107
  273. package/scripts/extract_todos.py +445 -404
  274. package/scripts/fav_server.py +119 -12
  275. package/scripts/fix_topics.py +111 -111
  276. package/scripts/generate_ai_report.py +1 -1
  277. package/scripts/generate_html.py +597 -277
  278. package/scripts/inspection_records.py +354 -0
  279. package/scripts/mcp_bridge.py +398 -398
  280. package/scripts/nt_decrypt.py +702 -48
  281. package/scripts/pipeline.py +27 -19
  282. package/scripts/promote_all.py +312 -310
  283. package/scripts/promote_ideas.py +299 -297
  284. package/scripts/rag_chat.py +194 -191
  285. package/scripts/semantic_search.py +435 -431
  286. package/scripts/sync_fav.py +201 -146
  287. package/scripts/sync_weread.py +192 -192
  288. package/scripts/vault_rag.py +140 -139
  289. package/scripts/vault_search.py +141 -141
  290. package/scripts/wechat_emoji.py +132 -0
  291. package/scripts/wechat_emoticon.py +433 -0
  292. package/scripts/wechat_image.py +258 -0
  293. package/scripts/wechat_voice.py +327 -0
  294. package/src/core/dbPathService.ts +163 -18
  295. package/src/core/keyService.ts +137 -25
  296. package/src/core/ntCore.ts +215 -62
  297. package/src/core/sqlcipherCore.ts +17 -1
  298. package/src/core/wcdbCore.ts +3 -7
  299. package/src/services/assistantDaemon.ts +111 -0
  300. package/src/services/assistantMemory.ts +193 -0
  301. package/src/services/assistantPrivacy.ts +87 -0
  302. package/src/services/assistantRouting.ts +92 -0
  303. package/src/services/assistantService.ts +295 -0
  304. package/src/services/assistantTools.ts +549 -0
  305. package/src/services/chatService.ts +133 -4
  306. package/src/services/configService.ts +194 -6
  307. package/src/services/evidenceService.ts +129 -0
  308. package/src/services/exportService.ts +113 -48
  309. package/src/services/initKeyService.ts +83 -0
  310. package/src/services/messageContract.ts +77 -0
  311. package/src/services/messageQuery.ts +38 -0
  312. package/src/services/wechat-formatter.ts +348 -348
  313. package/src/services/wechatMessageService.ts +25 -3
  314. package/src/services/wereadService.ts +308 -308
  315. package/src/services/whitelistService.ts +209 -22
  316. package/src/types.ts +20 -0
  317. package/src/utils/dateRange.ts +58 -0
  318. package/src/utils/mcpSecurity.ts +39 -0
  319. package/src/utils/packageRoot.ts +14 -0
  320. package/src/utils/python.ts +73 -0
  321. package/src/utils/pythonProcessEnv.ts +45 -0
  322. package/src/utils/talkerUtils.ts +14 -2
  323. package/dist/src/utils/errors.d.ts +0 -25
  324. package/dist/src/utils/errors.d.ts.map +0 -1
  325. package/dist/src/utils/errors.js +0 -43
  326. package/dist/src/utils/errors.js.map +0 -1
  327. package/dist/src/utils/pythonRunner.d.ts +0 -27
  328. package/dist/src/utils/pythonRunner.d.ts.map +0 -1
  329. package/dist/src/utils/pythonRunner.js +0 -62
  330. package/dist/src/utils/pythonRunner.js.map +0 -1
  331. package/src/utils/errors.ts +0 -42
  332. package/src/utils/pythonRunner.ts +0 -81
@@ -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)} | 自己: {own_wxid[:20]}...')
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()