weflow-cli 1.5.0 → 1.6.0

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