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
@@ -5,6 +5,7 @@ Splits large conversations into multiple parts.
5
5
  Embeds cached image thumbnails from NT cache directory.
6
6
  """
7
7
  import sys
8
+ import time
8
9
  import os
9
10
  import hashlib
10
11
  import datetime
@@ -13,6 +14,9 @@ import re
13
14
  import base64
14
15
  import urllib.request
15
16
  import urllib.error
17
+ import concurrent.futures
18
+ import struct
19
+ import json
16
20
  from pathlib import Path
17
21
 
18
22
  try:
@@ -27,7 +31,97 @@ MSG_TYPES = {
27
31
  43: 'video', 47: 'emoji', 48: 'location', 49: 'link',
28
32
  50: 'voip', 10000: 'system', 10002: 'quote',
29
33
  }
30
- MAX_EMBED_SIZE = 256 * 1024 # Max 256KB per embedded image
34
+ MAX_EMBED_SIZE = 8 * 1024 * 1024 # Bound self-contained HTML growth per image.
35
+ V2_MAGIC = b'\x07\x08V2\x08\x07'
36
+ V2_CIPHERTEXT_START = 0x0F
37
+ BUILTIN_EMOJI_DIR = os.path.join(os.path.dirname(__file__), '..', 'resources', 'wechat-emoji')
38
+ # Labels used by WeChat's built-in default emoji. These messages may only
39
+ # retain a PUA/signature marker in the export, so their CDN media is not
40
+ # recoverable from the message row itself.
41
+ #
42
+ # The table is derived from the artwork actually present in BUILTIN_EMOJI_DIR
43
+ # rather than hand-maintained: adding a PNG named after the face is enough.
44
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
45
+ try:
46
+ import wechat_emoji
47
+ BUILTIN_EMOJI_MAP = {f'[{name}]': name for name in wechat_emoji.IMAGE_FACES}
48
+ _WECHAT_EMOJI = True
49
+ import wechat_emoji as _face_index
50
+ except Exception:
51
+ BUILTIN_EMOJI_MAP = {}
52
+ _WECHAT_EMOJI = False
53
+
54
+ try:
55
+ import wechat_emoticon
56
+ _WECHAT_EMOTICON = True
57
+ except Exception:
58
+ _WECHAT_EMOTICON = False
59
+
60
+ try:
61
+ import wechat_image
62
+ _WECHAT_IMAGE = True
63
+ except Exception:
64
+ _WECHAT_IMAGE = False
65
+
66
+ try:
67
+ import wechat_voice
68
+ _WECHAT_VOICE = True
69
+ except Exception:
70
+ _WECHAT_VOICE = False
71
+
72
+ # Remote media is the single slowest step of an export: each miss costs a page
73
+ # fetch plus an image download, and inline thumbnails alone run to several
74
+ # hundred per conversation. Bound it per run and keep results on disk, misses
75
+ # included, so re-exports are instant. Set by main().
76
+ COVER_STATE = {
77
+ 'dir': '', 'budget': 0, 'fetched': 0, 'cached': 0, 'skipped': 0,
78
+ 'thumb_budget': 0, 'thumb_fetched': 0, 'thumb_cached': 0, 'thumb_skipped': 0,
79
+ }
80
+
81
+ # Off by default: full-resolution originals are 5-24MB / 3000-5700px each, and
82
+ # downscaling ~500 of them costs minutes (PIL decode dominates). The cache's
83
+ # own thumbnails need no processing at all and are what an export normally
84
+ # wants. Turn on for maximum fidelity at the cost of a much slower export.
85
+ FULL_IMAGES = os.environ.get('WEFLOW_FULL_IMAGES', '') == '1'
86
+
87
+ # og:image lives in <head>; WeChat article pages are multi-MB, so reading the
88
+ # whole thing to find it wastes seconds per article.
89
+ COVER_HEAD_BYTES = 64 * 1024
90
+
91
+ # Base64 length above which an account-index original is re-encoded before
92
+ # embedding. Below it the file is already display-sized and PIL would only
93
+ # cost time, which matters across thousands of media entries.
94
+ EMBED_SHRINK_THRESHOLD = 400 * 1024
95
+
96
+ # Two separate budgets: share-page covers are rare and expensive, inline
97
+ # appmsg thumbnails are common and cheap. Sharing one pool lets a run of
98
+ # thumbnails starve the covers that actually change how a page looks.
99
+ COVER_FETCH_LIMIT = 60
100
+ THUMB_FETCH_LIMIT = 300
101
+
102
+ # Set up by main(); custom stickers decrypted from WeChat's local cache.
103
+ STICKER_STATE = {'key': b'', 'dirs': [], 'cache_dir': ''}
104
+
105
+ # {wxid: remark/nickname}, loaded by main(). Group rows name their sender only
106
+ # by wxid, so without this a group transcript is unreadable. Empty when the
107
+ # contact database is unavailable, in which case ids are shown as-is.
108
+ CONTACT_NAMES = {}
109
+
110
+ # Voice messages: {local_id: silk bytes} for this conversation, plus the
111
+ # transcript cache. The export only ever *reads* transcripts - recognising
112
+ # speech runs ~1.4x the audio's own length on CPU, so it is a separate,
113
+ # resumable pass (`scripts/wechat_voice.py`) rather than part of an export.
114
+ VOICE_STATE = {'map': {}, 'cache': None}
115
+
116
+ # Remote media is discovered one message at a time, but a conversation needs
117
+ # it from hundreds of messages at once - and a single message rarely needs
118
+ # more than one URL, so nothing is ever fetched in parallel. main() therefore
119
+ # runs a throwaway formatting pass that only records the URLs (0.1s, no
120
+ # network), fetches them concurrently, then formats for real against a warm
121
+ # cache. Set to a list during that pass; None otherwise.
122
+ PREFETCH = {'sink': None, 'active': False}
123
+ PREFETCH_WORKERS = 24
124
+ PREFETCH_MAX_URLS = 800
31
125
 
32
126
 
33
127
  def connect(db_path, key_hex, salt_hex):
@@ -38,21 +132,35 @@ def connect(db_path, key_hex, salt_hex):
38
132
  return conn, c
39
133
 
40
134
 
41
- def fetch_messages(conn, talker):
135
+ def fetch_messages(conn, talker, date=''):
42
136
  """Fetch all messages for a talker, ordered by time ascending."""
43
137
  tbl = 'Msg_' + hashlib.md5(talker.encode()).hexdigest()
44
138
  c = conn.cursor()
45
139
 
140
+ c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (tbl,))
141
+ if not c.fetchone():
142
+ return []
143
+
46
144
  c.execute(f"SELECT COUNT(*) FROM \"{tbl}\"")
47
145
  total = c.fetchone()[0]
48
146
  print(f"Total messages: {total}")
49
147
 
148
+ date_filter = ''
149
+ date_params = []
150
+ if date:
151
+ try:
152
+ day = datetime.datetime.strptime(date, '%Y-%m-%d')
153
+ except ValueError:
154
+ raise ValueError('date must use YYYY-MM-DD')
155
+ date_filter = ' WHERE create_time >= ? AND create_time < ?'
156
+ date_params = [int(day.timestamp()), int((day + datetime.timedelta(days=1)).timestamp())]
157
+
50
158
  c.execute(f'''
51
159
  SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
52
160
  create_time, status, source, message_content, compress_content
53
- FROM "{tbl}"
161
+ FROM "{tbl}"{date_filter}
54
162
  ORDER BY create_time ASC
55
- ''')
163
+ ''', date_params)
56
164
 
57
165
  messages = []
58
166
  batch = 0
@@ -68,6 +176,52 @@ def fetch_messages(conn, talker):
68
176
  return messages
69
177
 
70
178
 
179
+ def discover_message_shards(db_path):
180
+ """Return all NT message shards alongside the configured database."""
181
+ db = Path(db_path)
182
+ candidates = sorted(db.parent.glob('message_*.db'))
183
+ return [str(path) for path in candidates if path.name.lower() not in {
184
+ 'message_fts.db', 'message_resource.db'
185
+ }]
186
+
187
+
188
+ def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
189
+ """Derive a shard-specific SQLCipher key from the shared NT passphrase."""
190
+ if not passphrase:
191
+ return fallback_key, fallback_salt
192
+ try:
193
+ with open(path, 'rb') as fh:
194
+ salt = fh.read(16)
195
+ if len(salt) != 16:
196
+ return fallback_key, fallback_salt
197
+ raw_passphrase = bytes.fromhex(passphrase)
198
+ key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32).hex()
199
+ return key, salt.hex()
200
+ except (OSError, ValueError):
201
+ return fallback_key, fallback_salt
202
+
203
+
204
+ def fetch_messages_from_shards(db_path, key_hex, salt_hex, talker, date='', passphrase=''):
205
+ """Read and merge messages from every NT message shard."""
206
+ shards = discover_message_shards(db_path)
207
+ if not shards:
208
+ shards = [db_path]
209
+ messages = []
210
+ for shard in shards:
211
+ shard_conn = None
212
+ try:
213
+ shard_key, shard_salt = derive_database_key(shard, key_hex, salt_hex, passphrase)
214
+ shard_conn, _ = connect(shard, shard_key, shard_salt)
215
+ messages.extend(fetch_messages(shard_conn, talker, date))
216
+ except Exception:
217
+ continue
218
+ finally:
219
+ if shard_conn is not None:
220
+ shard_conn.close()
221
+ messages.sort(key=lambda row: (int(row[5] or 0), int(row[0] or 0)))
222
+ return messages
223
+
224
+
71
225
  def build_sender_map(conn, talker):
72
226
  """Map sender_id -> display name using Name2Id table and contact DB."""
73
227
  sender_map = {}
@@ -89,7 +243,24 @@ def build_sender_map(conn, talker):
89
243
  return sender_map
90
244
 
91
245
 
92
- def scan_nt_cache(nt_cache_dir, talker):
246
+ def build_sender_map_from_shards(db_path, key_hex, salt_hex, talker, passphrase=''):
247
+ """Merge sender mappings from every message shard."""
248
+ sender_map = {}
249
+ for shard in discover_message_shards(db_path):
250
+ shard_conn = None
251
+ try:
252
+ shard_key, shard_salt = derive_database_key(shard, key_hex, salt_hex, passphrase)
253
+ shard_conn, _ = connect(shard, shard_key, shard_salt)
254
+ sender_map.update(build_sender_map(shard_conn, talker))
255
+ except Exception:
256
+ continue
257
+ finally:
258
+ if shard_conn is not None:
259
+ shard_conn.close()
260
+ return sender_map
261
+
262
+
263
+ def scan_nt_cache(nt_cache_dir, talker, account_dir='', own_wxid=''):
93
264
  """Scan NT cache directory for image thumbnails and temp images.
94
265
 
95
266
  NT cache structure:
@@ -131,7 +302,13 @@ def scan_nt_cache(nt_cache_dir, talker):
131
302
  with open(fpath, 'rb') as fh:
132
303
  data = fh.read()
133
304
  if len(data) < MAX_EMBED_SIZE:
305
+ if _WECHAT_IMAGE and FULL_IMAGES:
306
+ data, mime = wechat_image.shrink(data, mime, max_side=480)
134
307
  image_map[local_id] = (base64.b64encode(data).decode(), mime)
308
+ cache_time = parse_cache_timestamp(fname)
309
+ if cache_time:
310
+ image_map[f'pair:{local_id}:{cache_time}'] = image_map[local_id]
311
+ image_map[f'time:{cache_time}'] = image_map[local_id]
135
312
  except:
136
313
  pass
137
314
 
@@ -152,18 +329,435 @@ def scan_nt_cache(nt_cache_dir, talker):
152
329
  with open(fpath, 'rb') as fh:
153
330
  data = fh.read()
154
331
  if len(data) < MAX_EMBED_SIZE:
155
- image_map[local_id] = (base64.b64encode(data).decode(), 'image/jpeg')
332
+ if _WECHAT_IMAGE and FULL_IMAGES:
333
+ data, _m = wechat_image.shrink(data, 'image/jpeg', max_side=480)
334
+ image_map[local_id] = (base64.b64encode(data).decode(), _m if _WECHAT_IMAGE else 'image/jpeg')
335
+ cache_time = parse_cache_timestamp(fname)
336
+ if cache_time:
337
+ image_map[f'pair:{local_id}:{cache_time}'] = image_map[local_id]
338
+ image_map[f'time:{cache_time}'] = image_map[local_id]
156
339
  except:
157
340
  pass
158
341
 
342
+ for root, _, files in os.walk(msg_dir):
343
+ if root == img_temp_dir:
344
+ continue
345
+ for fname in files:
346
+ parts = fname.split('_', 1)
347
+ local_id = int(parts[0]) if parts and parts[0].isdigit() else None
348
+ file_md5 = extract_media_md5(fname)
349
+ if local_id is None and not file_md5:
350
+ continue
351
+ cache_time = parse_cache_timestamp(fname)
352
+ if local_id is not None and (local_id in image_map or f'time:{cache_time}' in image_map):
353
+ continue
354
+ if file_md5 and f'md5:{file_md5}' in image_map:
355
+ continue
356
+ fpath = os.path.join(root, fname)
357
+ try:
358
+ if os.path.getsize(fpath) >= MAX_EMBED_SIZE:
359
+ continue
360
+ mime = detect_mime(fpath)
361
+ if not mime:
362
+ continue
363
+ with open(fpath, 'rb') as fh:
364
+ data = fh.read()
365
+ if data:
366
+ if _WECHAT_IMAGE and FULL_IMAGES:
367
+ data, mime = wechat_image.shrink(data, mime, max_side=480)
368
+ if not data:
369
+ continue
370
+ image = (base64.b64encode(data).decode(), mime)
371
+ if local_id is not None:
372
+ image_map[local_id] = image
373
+ if cache_time:
374
+ if local_id is not None:
375
+ image_map[f'pair:{local_id}:{cache_time}'] = image
376
+ image_map[f'time:{cache_time}'] = image
377
+ if file_md5:
378
+ image_map[f'md5:{file_md5}'] = image
379
+ except OSError:
380
+ continue
381
+
382
+ # The account media index is where full-resolution originals live; without
383
+ # it an export uses only this conversation's own cache thumbnails.
384
+ if FULL_IMAGES and account_dir and os.path.isdir(account_dir):
385
+ for key, image in scan_account_media(account_dir, own_wxid, talker).items():
386
+ if key.startswith('md5:'):
387
+ image_map.setdefault(key, image)
388
+
389
+ return image_map
390
+
391
+
392
+ def scan_account_media(account_dir, own_wxid='', talker=''):
393
+ """Index image resources stored outside a conversation cache directory.
394
+
395
+ Scoped to `talker` whenever it is known. Walking the whole account means
396
+ stat-ing and probing ~20k files (and 255-way XOR on every non-image), which
397
+ dominated export time; a conversation only ever needs its own media.
398
+ """
399
+ image_map = {}
400
+ v2_key = resolve_v2_media_key(account_dir, own_wxid)
401
+ if talker:
402
+ talker_md5 = hashlib.md5(talker.encode()).hexdigest()
403
+ roots = [os.path.join(account_dir, 'msg', 'attach', talker_md5)]
404
+ cache_root = os.path.join(account_dir, 'cache')
405
+ if os.path.isdir(cache_root):
406
+ for month in sorted(os.listdir(cache_root)):
407
+ d = os.path.join(cache_root, month, 'Message', talker_md5)
408
+ if os.path.isdir(d):
409
+ roots.append(d)
410
+ else:
411
+ roots = [
412
+ os.path.join(account_dir, 'cache'),
413
+ os.path.join(account_dir, 'msg'),
414
+ os.path.join(account_dir, 'resource'),
415
+ os.path.join(account_dir, 'business'),
416
+ os.path.join(account_dir, 'temp'),
417
+ ]
418
+ seen = set()
419
+ for root in roots:
420
+ if not os.path.isdir(root):
421
+ continue
422
+ for current_root, _, files in os.walk(root):
423
+ for fname in files:
424
+ path = os.path.join(current_root, fname)
425
+ try:
426
+ stat = os.stat(path)
427
+ if stat.st_size <= 16 or stat.st_size > MAX_EMBED_SIZE:
428
+ continue
429
+ real_path = os.path.realpath(path)
430
+ if real_path in seen:
431
+ continue
432
+ seen.add(real_path)
433
+ with open(path, 'rb') as fh:
434
+ header = fh.read(64)
435
+ mime = detect_mime_from_bytes(header[:16])
436
+ if not mime:
437
+ decoded = decode_wechat_media(header, path, v2_key)
438
+ if decoded:
439
+ data, mime = decoded
440
+ else:
441
+ with open(path, 'rb') as fh:
442
+ data = fh.read(MAX_EMBED_SIZE + 1)
443
+ if not mime or len(data) > MAX_EMBED_SIZE:
444
+ continue
445
+ image = (base64.b64encode(data).decode(), mime)
446
+ for media_md5 in extract_media_md5s(fname):
447
+ image_map.setdefault(f'md5:{media_md5}', image)
448
+ content_md5 = hashlib.md5(data).hexdigest()
449
+ image_map.setdefault(f'md5:{content_md5}', image)
450
+ except (OSError, ValueError):
451
+ continue
159
452
  return image_map
160
453
 
161
454
 
455
+ def clean_account_wxid(value):
456
+ value = str(value or '').strip()
457
+ parts = value.rsplit('_', 1)
458
+ if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
459
+ return parts[0]
460
+ return value
461
+
462
+
463
+ def resolve_v2_media_key(account_dir, own_wxid='', kvcomm_dir=''):
464
+ """Derive and verify the local WeChat V2 image key without persisting it."""
465
+ if not account_dir:
466
+ return None
467
+ if not kvcomm_dir:
468
+ appdata = os.environ.get('APPDATA', '')
469
+ kvcomm_dir = os.path.join(appdata, 'Tencent', 'xwechat', 'net', 'kvcomm')
470
+ try:
471
+ codes = sorted({
472
+ int(match.group(1))
473
+ for name in os.listdir(kvcomm_dir)
474
+ if (match := re.fullmatch(r'key_(\d+)_.+\.statistic', name, re.IGNORECASE))
475
+ })
476
+ except (OSError, ValueError):
477
+ return None
478
+ if not codes:
479
+ return None
480
+
481
+ templates = []
482
+ for root in ('msg', 'cache', 'resource'):
483
+ search_root = os.path.join(account_dir, root)
484
+ if not os.path.isdir(search_root):
485
+ continue
486
+ for current_root, _, files in os.walk(search_root):
487
+ for name in files:
488
+ if not name.lower().endswith('_t.dat'):
489
+ continue
490
+ path = os.path.join(current_root, name)
491
+ try:
492
+ with open(path, 'rb') as stream:
493
+ header = stream.read(V2_CIPHERTEXT_START + 16)
494
+ if header.startswith(V2_MAGIC) and len(header) >= V2_CIPHERTEXT_START + 16:
495
+ templates.append(header[V2_CIPHERTEXT_START:V2_CIPHERTEXT_START + 16])
496
+ except OSError:
497
+ continue
498
+ if len(templates) >= 32:
499
+ break
500
+ if len(templates) >= 32:
501
+ break
502
+ if len(templates) >= 32:
503
+ break
504
+ if not templates:
505
+ return None
506
+
507
+ wxids = list(dict.fromkeys(filter(None, (
508
+ clean_account_wxid(own_wxid),
509
+ clean_account_wxid(Path(account_dir).name),
510
+ ))))
511
+ try:
512
+ from Crypto.Cipher import AES
513
+ except ImportError:
514
+ return None
515
+ for wxid in wxids:
516
+ for code in codes:
517
+ aes_key = hashlib.md5(f'{code}{wxid}'.encode()).hexdigest()[:16].encode('ascii')
518
+ try:
519
+ plaintext = AES.new(aes_key, AES.MODE_ECB).decrypt(templates[0])
520
+ except (TypeError, ValueError):
521
+ continue
522
+ if detect_mime_from_bytes(plaintext) or plaintext.startswith((b'wxgf', b'WXGF')):
523
+ return code & 0xff, aes_key
524
+ return None
525
+
526
+
527
+ def decode_wechat_v2(filepath, xor_key, aes_key):
528
+ try:
529
+ from Crypto.Cipher import AES
530
+ from Crypto.Util import Padding
531
+ with open(filepath, 'rb') as stream:
532
+ data = stream.read(MAX_EMBED_SIZE + 1)
533
+ if len(data) > MAX_EMBED_SIZE or not data.startswith(V2_MAGIC):
534
+ return None
535
+ signature, aes_size, xor_size = struct.unpack('<6sLLx', data[:V2_CIPHERTEXT_START])
536
+ if signature != V2_MAGIC:
537
+ return None
538
+ encrypted_size = aes_size + 16 - aes_size % 16
539
+ encrypted = data[V2_CIPHERTEXT_START:V2_CIPHERTEXT_START + encrypted_size]
540
+ decrypted = Padding.unpad(AES.new(aes_key, AES.MODE_ECB).decrypt(encrypted), 16)
541
+ remainder = data[V2_CIPHERTEXT_START + encrypted_size:]
542
+ if xor_size:
543
+ if xor_size > len(remainder):
544
+ return None
545
+ raw = remainder[:-xor_size]
546
+ tail = bytes(value ^ xor_key for value in remainder[-xor_size:])
547
+ else:
548
+ raw, tail = remainder, b''
549
+ output = decrypted + raw + tail
550
+ mime = detect_mime_from_bytes(output[:16])
551
+ return (output, mime) if mime else None
552
+ except (OSError, ValueError, struct.error):
553
+ return None
554
+
555
+
556
+ def decode_wechat_media(data, filepath=None, v2_key=None):
557
+ """Decode common XOR-obfuscated WeChat image cache payloads."""
558
+ if not data or len(data) < 16:
559
+ return None
560
+ if data.startswith(V2_MAGIC) and filepath and v2_key:
561
+ # No downscaling here: this is called once per file while building the
562
+ # account media index (tens of thousands of files), so doing image work
563
+ # at this layer makes the index build take minutes. Shrinking happens
564
+ # in get_cached_image(), which only runs for images actually embedded.
565
+ return decode_wechat_v2(filepath, *v2_key)
566
+ for key in range(1, 256):
567
+ decoded = bytes(value ^ key for value in data[: min(len(data), 64)])
568
+ mime = detect_mime_from_bytes(decoded)
569
+ if mime:
570
+ if filepath:
571
+ try:
572
+ with open(filepath, 'rb') as fh:
573
+ raw = fh.read(MAX_EMBED_SIZE + 1)
574
+ if len(raw) > MAX_EMBED_SIZE:
575
+ return None
576
+ except OSError:
577
+ return None
578
+ else:
579
+ raw = data
580
+ full = bytes(value ^ key for value in raw)
581
+ return full, mime
582
+ return None
583
+
584
+
585
+ def parse_cache_timestamp(filename):
586
+ parts = filename.split('_', 2)
587
+ if len(parts) < 2 or not parts[1].isdigit():
588
+ return 0
589
+ timestamp = int(parts[1])
590
+ if timestamp > 10_000_000_000:
591
+ timestamp //= 1000
592
+ return timestamp
593
+
594
+
595
+ def extract_media_md5(value):
596
+ values = extract_media_md5s(value)
597
+ return values[0] if values else ''
598
+
599
+
600
+ def extract_media_md5s(value):
601
+ matches = re.findall(r'(?<![0-9a-f])([0-9a-f]{32})(?![0-9a-f])', str(value or ''), re.IGNORECASE)
602
+ return list(dict.fromkeys(match.lower() for match in matches))
603
+
604
+
605
+ def extract_blob_md5s(value, known_md5s=None):
606
+ if value is None:
607
+ return []
608
+ if isinstance(value, memoryview):
609
+ data = value.tobytes()
610
+ elif isinstance(value, (bytes, bytearray)):
611
+ data = bytes(value)
612
+ else:
613
+ data = str(value).encode('utf-8', errors='ignore')
614
+ matches = re.findall(rb'(?i)([0-9a-f]{32})(?:[._][thbc])?\.dat', data)
615
+ if not matches:
616
+ matches = re.findall(rb'(?i)(?<![0-9a-f])([0-9a-f]{32})(?![0-9a-f])', data)
617
+ result = [item.decode('ascii').lower() for item in matches]
618
+ # MessageResourceInfo commonly stores MD5 values as raw 16-byte fields.
619
+ # Index those candidates; get_cached_image will retain only candidates
620
+ # that resolve to an actual local media file.
621
+ for offset in range(0, max(0, len(data) - 15)):
622
+ candidate = data[offset:offset + 16]
623
+ candidate_hex = candidate.hex()
624
+ if (candidate not in (b'\x00' * 16, b'\xff' * 16)
625
+ and (known_md5s is None or candidate_hex in known_md5s)):
626
+ result.append(candidate_hex)
627
+ return list(dict.fromkeys(result))
628
+
629
+
630
+ def load_resource_media_map(account_dir, key_hex, salt_hex, messages, image_map=None, passphrase=''):
631
+ """Load message-resource MD5s keyed by message IDs when available."""
632
+ if not account_dir or not messages:
633
+ return {}
634
+ resource_db = os.path.join(account_dir, 'db_storage', 'message', 'message_resource.db')
635
+ if not os.path.isfile(resource_db):
636
+ return {}
637
+ server_ids = {int(row[1] or 0) for row in messages if row[1]}
638
+ result = {}
639
+ known_md5s = {
640
+ key[4:].lower() for key in (image_map or {})
641
+ if isinstance(key, str) and key.startswith('md5:')
642
+ }
643
+ conn = None
644
+ try:
645
+ resource_key, resource_salt = derive_database_key(resource_db, key_hex, salt_hex, passphrase)
646
+ conn, cursor = connect(resource_db, resource_key, resource_salt)
647
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=lower('MessageResourceInfo')")
648
+ table = cursor.fetchone()
649
+ if not table:
650
+ conn.close()
651
+ return result
652
+ cursor.execute('SELECT message_svr_id, message_local_id, packed_info FROM "MessageResourceInfo"')
653
+ for server_id, local_id, packed_info in cursor.fetchall():
654
+ sid = int(server_id or 0)
655
+ # Local IDs can collide across conversations and database shards.
656
+ if not sid or sid not in server_ids:
657
+ continue
658
+ md5s = extract_blob_md5s(packed_info, known_md5s)
659
+ if md5s:
660
+ result.setdefault(f'server:{sid}', []).extend(md5s)
661
+ conn.close()
662
+ except Exception:
663
+ try:
664
+ conn.close()
665
+ except Exception:
666
+ pass
667
+ return {key: list(dict.fromkeys(values)) for key, values in result.items()}
668
+
669
+
670
+ _SHRINK_MEMO = {}
671
+
672
+
673
+ def shrink_embedded(image, max_side=720, force=False):
674
+ """Downscale an (base64, mime) pair on the way out of the media index.
675
+
676
+ The index deliberately keeps originals: it is built over the whole account
677
+ and only a fraction of it ever gets embedded, so image work belongs here
678
+ rather than there.
679
+
680
+ `force` skips the opt-in gate, for media that has no smaller alternative -
681
+ an original pulled in from the account index because the conversation
682
+ cache never held it at all.
683
+ """
684
+ if not image or not _WECHAT_IMAGE:
685
+ return image
686
+ if not force and not FULL_IMAGES:
687
+ return image
688
+ memo_key = image[0][:64] + ':' + str(len(image[0]))
689
+ if memo_key in _SHRINK_MEMO:
690
+ return _SHRINK_MEMO[memo_key]
691
+
692
+ # Disk cache: shrinking a multi-MB photo costs ~0.15s, and the same media
693
+ # comes back on every export. Key is the source digest, so this is safe to
694
+ # share across conversations.
695
+ cache_dir = COVER_STATE.get('imgshrink') or ''
696
+ disk_key = hashlib.md5(image[0][:4096].encode()).hexdigest() + str(len(image[0]))
697
+ disk_path = os.path.join(cache_dir, disk_key) if cache_dir else ''
698
+ if disk_path and os.path.isfile(disk_path):
699
+ try:
700
+ with open(disk_path, 'rb') as fh:
701
+ blob = fh.read()
702
+ nl = blob.find(b'\n')
703
+ if nl > 0:
704
+ result = (blob[:nl].decode(), blob[nl + 1:].decode())
705
+ _SHRINK_MEMO[memo_key] = result
706
+ return result
707
+ except OSError:
708
+ pass
709
+
710
+ try:
711
+ raw = base64.b64decode(image[0])
712
+ smaller, mime = wechat_image.shrink(raw, image[1], max_side=max_side)
713
+ result = image if smaller is raw else (base64.b64encode(smaller).decode(), mime)
714
+ _SHRINK_MEMO[memo_key] = result
715
+ if disk_path and smaller is not raw:
716
+ try:
717
+ os.makedirs(cache_dir, exist_ok=True)
718
+ with open(disk_path, 'wb') as fh:
719
+ fh.write(result[0].encode() + b'\n' + result[1].encode())
720
+ except OSError:
721
+ pass
722
+ return result
723
+ except Exception:
724
+ return image
725
+
726
+
727
+ def get_cached_image(image_map, local_id, create_time, content='', resource_md5s=None, server_id=0):
728
+ """Best local media for a message: by content md5, then by identity.
729
+
730
+ md5 is the strongest key, but it is not always derivable - a group row can
731
+ lack the resource mapping that carries it. The conversation cache also
732
+ indexes by `(local_id, create_time)`, which is identity-aware and was
733
+ already being stored; reading it recovers media the md5 path alone misses.
734
+
735
+ Deliberately *not* falling back to a bare `local_id`: that id restarts in
736
+ every message shard, so matching on it alone attaches one conversation's
737
+ image to another conversation's message.
738
+ """
739
+ if not image_map:
740
+ return None
741
+ for media_md5 in resource_md5s or []:
742
+ if image_map.get(f'md5:{media_md5}'):
743
+ return shrink_embedded(image_map[f'md5:{media_md5}'])
744
+ for media_md5 in extract_media_md5s(content):
745
+ if image_map.get(f'md5:{media_md5}'):
746
+ return shrink_embedded(image_map[f'md5:{media_md5}'])
747
+ try:
748
+ pair_key = f'pair:{int(local_id)}:{int(create_time)}'
749
+ except (TypeError, ValueError):
750
+ return None
751
+ if image_map.get(pair_key):
752
+ return shrink_embedded(image_map[pair_key])
753
+ return None
754
+
755
+
162
756
  def detect_mime(filepath):
163
757
  """Detect MIME type from file header."""
164
758
  try:
165
759
  with open(filepath, 'rb') as f:
166
- header = f.read(8)
760
+ header = f.read(12)
167
761
  if header[:2] == b'\xff\xd8':
168
762
  return 'image/jpeg'
169
763
  if header[:4] == b'\x89PNG':
@@ -194,9 +788,10 @@ def find_thumbnail(create_time, msg_local_id, wx_dir):
194
788
  fpath = os.path.join(img_dir, f)
195
789
  if not os.path.isfile(fpath):
196
790
  continue
791
+ if not f.startswith(f'{msg_local_id}_'):
792
+ continue
197
793
  fstat = os.stat(fpath)
198
- time_diff = abs(fstat.st_mtime - create_time)
199
- if time_diff < 300 and os.path.getsize(fpath) < MAX_EMBED_SIZE:
794
+ if os.path.getsize(fpath) < MAX_EMBED_SIZE:
200
795
  with open(fpath, 'rb') as fh:
201
796
  data = fh.read()
202
797
  if len(data) < MAX_EMBED_SIZE:
@@ -206,28 +801,145 @@ def find_thumbnail(create_time, msg_local_id, wx_dir):
206
801
  return None
207
802
 
208
803
 
209
- def download_image_as_base64(url, timeout=10):
210
- """Download image from URL and return (base64_data, mime_type) or None."""
211
- if not url or not url.startswith(('http://', 'https://')):
804
+ def _decrypt_aes_cbc(payload, key_hex):
805
+ key_hex = re.sub(r'[^0-9a-f]', '', str(key_hex or ''), flags=re.IGNORECASE)
806
+ if len(key_hex) != 32:
212
807
  return None
213
808
  try:
214
- req = urllib.request.Request(url, headers={
215
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
216
- 'Referer': 'https://mp.weixin.qq.com/',
217
- })
218
- with urllib.request.urlopen(req, timeout=timeout) as resp:
219
- data = resp.read(MAX_EMBED_SIZE + 1)
220
- if len(data) > MAX_EMBED_SIZE:
221
- return None
222
- mime = resp.headers.get('Content-Type', 'image/jpeg').split(';')[0].strip()
223
- if mime not in ('image/jpeg', 'image/png', 'image/gif', 'image/webp'):
224
- # Try detecting from data
225
- mime = detect_mime_from_bytes(data) or 'image/jpeg'
226
- return (base64.b64encode(data).decode(), mime)
809
+ from Crypto.Cipher import AES
810
+ key = bytes.fromhex(key_hex)
811
+ decrypted = AES.new(key, AES.MODE_CBC, key).decrypt(payload)
812
+ padding = decrypted[-1] if decrypted else 0
813
+ if 0 < padding <= AES.block_size and decrypted.endswith(bytes([padding]) * padding):
814
+ decrypted = decrypted[:-padding]
815
+ return decrypted
227
816
  except Exception:
228
817
  return None
229
818
 
230
819
 
820
+ def _valid_media(data):
821
+ if not data:
822
+ return None
823
+ mime = detect_mime_from_bytes(data[:16])
824
+ if mime:
825
+ return data, mime
826
+ return None
827
+
828
+
829
+ # Writes a file with no '\n', which the (b64, mime) format can never produce.
830
+ NEGATIVE_MARKER = b'!'
831
+
832
+
833
+ def _cache_media(cache_file, payload):
834
+ """Store a downloaded (b64, mime) pair, or a negative marker when None.
835
+
836
+ Misses are cached too. The bulk of these URLs are dead WeChat CDN links,
837
+ so without a negative cache every re-export re-attempted all of them.
838
+ """
839
+ if not cache_file:
840
+ return
841
+ try:
842
+ os.makedirs(os.path.dirname(cache_file), exist_ok=True)
843
+ if payload is None:
844
+ with open(cache_file, 'wb') as fh:
845
+ fh.write(NEGATIVE_MARKER)
846
+ else:
847
+ with open(cache_file, 'wb') as fh:
848
+ fh.write(payload[0].encode() + b'\n' + payload[1].encode())
849
+ except OSError:
850
+ pass
851
+
852
+
853
+ def _read_cache_media(cache_file):
854
+ """Read a cached download.
855
+
856
+ Returns a (b64, mime) pair, None for a cached miss, or 'unknown' when the
857
+ path is absent or unreadable and a fetch should be attempted.
858
+ """
859
+ if not cache_file or not os.path.isfile(cache_file):
860
+ return 'unknown'
861
+ try:
862
+ with open(cache_file, 'rb') as fh:
863
+ blob = fh.read()
864
+ except OSError:
865
+ return 'unknown'
866
+ if blob == NEGATIVE_MARKER:
867
+ return None
868
+ sep = blob.find(b'\n')
869
+ if sep <= 0:
870
+ return 'unknown'
871
+ return (blob[:sep].decode(), blob[sep + 1:].decode())
872
+
873
+
874
+ def download_image_as_base64(url, aes_key='', timeout=10, budgeted=True):
875
+ """Download image from URL and return (base64_data, mime_type) or None.
876
+
877
+ Cached on disk and budgeted per run: this is the hot path for every appmsg
878
+ thumbnail and sticker fallback, and it accounted for the entire runtime of
879
+ a link-heavy export before either guard existed.
880
+
881
+ `budgeted=False` is for calls made *by* the cover fetchers, which have
882
+ already spent their own budget for this URL.
883
+ """
884
+ if not url or not url.startswith(('http://', 'https://')):
885
+ return None
886
+
887
+ sink = PREFETCH['sink']
888
+ if sink is not None:
889
+ # Dry pass: record the request, spend no budget, touch no network.
890
+ sink.append(('img', url, aes_key))
891
+ return None
892
+
893
+ cache_dir = COVER_STATE.get('dir') or ''
894
+ # The AES key changes the bytes, so it belongs in the cache identity.
895
+ slug = hashlib.md5(f'{url}\x00{aes_key}'.encode()).hexdigest()
896
+ cache_file = os.path.join(cache_dir, slug + '.b64') if cache_dir else ''
897
+
898
+ cached = _read_cache_media(cache_file)
899
+ if cached != 'unknown':
900
+ if budgeted:
901
+ COVER_STATE['thumb_cached'] += 1
902
+ return cached
903
+
904
+ if budgeted and not PREFETCH['active']:
905
+ if COVER_STATE.get('thumb_budget', 0) <= 0:
906
+ COVER_STATE['thumb_skipped'] += 1
907
+ return None
908
+ COVER_STATE['thumb_budget'] -= 1
909
+ COVER_STATE['thumb_fetched'] += 1
910
+
911
+ for _ in range(2):
912
+ try:
913
+ req = urllib.request.Request(url, headers={
914
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
915
+ 'Referer': 'https://mp.weixin.qq.com/',
916
+ })
917
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
918
+ data = resp.read(MAX_EMBED_SIZE + 1)
919
+ # Too large and unparseable both mean "nothing usable here"; record
920
+ # it so the next export does not pay for the same dead URL.
921
+ if len(data) > MAX_EMBED_SIZE:
922
+ _cache_media(cache_file, None)
923
+ return None
924
+ candidates = [data]
925
+ decrypted = _decrypt_aes_cbc(data, aes_key)
926
+ if decrypted:
927
+ candidates.insert(0, decrypted)
928
+ detected = next((hit for hit in map(_valid_media, candidates) if hit), None)
929
+ if detected:
930
+ data, mime = detected
931
+ if _WECHAT_IMAGE:
932
+ # Covers arrive full-size; the reader shows them at 240px.
933
+ data, mime = wechat_image.shrink(data, mime, max_side=480)
934
+ result = (base64.b64encode(data).decode(), mime)
935
+ _cache_media(cache_file, result)
936
+ return result
937
+ except Exception:
938
+ continue
939
+ _cache_media(cache_file, None)
940
+ return None
941
+
942
+
231
943
  def detect_mime_from_bytes(header_bytes):
232
944
  """Detect MIME type from byte header."""
233
945
  if header_bytes[:2] == b'\xff\xd8':
@@ -245,32 +957,556 @@ def extract_appmsg_image(content):
245
957
  """Extract image URL from appmsg XML content."""
246
958
  if not content:
247
959
  return None
248
- # Try common image URL fields in appmsg XML
249
- for tag in ('thumburl', 'cdnthumburl', 'appthumburl'):
250
- m = re.search(rf'<{tag}>([^<]+)</{tag}>', content)
960
+ for tag in ('encrypturl', 'thumburl', 'cdnthumburl', 'appthumburl'):
961
+ m = re.search(rf'<{tag}\b[^>]*>([\s\S]*?)</{tag}>', content, re.IGNORECASE)
251
962
  if m:
252
963
  url = m.group(1).strip()
253
- if url.startswith(('http://', 'https://')):
964
+ url = url.replace('<![CDATA[', '').replace(']]>', '').strip()
965
+ url = decode_xml(url).replace('\\/', '/').replace('*#*', ':').strip()
966
+ if url.startswith(('http://', 'https://')) and not is_share_page_url(url):
967
+ return url
968
+ for tag in ('encrypturl', 'thumburl', 'cdnthumburl', 'appthumburl'):
969
+ m = re.search(rf'\b{tag}\s*=\s*["\']([^"\']+)', content, re.IGNORECASE)
970
+ if m:
971
+ url = decode_xml(m.group(1).strip()).replace('\\/', '/').replace('*#*', ':').strip()
972
+ if url.startswith(('http://', 'https://')) and not is_share_page_url(url):
254
973
  return url
255
- # Also check for <msg><appmsg> nested structure
256
- m = re.search(r'<thumburl>([^<]+)</thumburl>', content)
257
- if m:
258
- url = m.group(1).strip()
259
- if url.startswith(('http://', 'https://')):
974
+ for raw_url in re.findall(r'https?://[^\s<>"\']+', str(content), re.IGNORECASE):
975
+ url = (decode_xml(raw_url).replace('\\/', '/').replace('\\u0026', '&')
976
+ .replace('*#*', ':').strip(' \t\r\n\\\'"'))
977
+ # App-card URLs (notably b23.tv/Bilibili share links) are page links,
978
+ # not image resources. Never emit them as a broken <img> source.
979
+ if url.startswith(('http://', 'https://')) and not is_share_page_url(url):
260
980
  return url
261
981
  return None
262
982
 
263
983
 
984
+ def is_share_page_url(url):
985
+ return bool(re.match(
986
+ r'https?://(?:www\.)?(?:b23\.tv|bilibili\.com|pan\.quark\.cn|'
987
+ r'y\.music\.163\.com|music\.163\.com|mp\.weixin\.qq\.com|'
988
+ r'schoai\.cn|share\.traecontent\.cn|hycx-gd\.cn|'
989
+ r'campusgateway\.51job\.com|tieba\.baidu\.com)(?:/|$)',
990
+ str(url or ''), re.IGNORECASE,
991
+ ))
992
+
993
+
994
+ def resolve_emoticon_seed(configured, account_dir, out_dir):
995
+ """The account's sticker seed, discovered from memory if unconfigured.
996
+
997
+ The seed is a per-account constant that only exists in WeChat's process
998
+ memory. Nothing used to populate it, so `STICKER_STATE['key']` stayed
999
+ empty, local sticker decryption never ran, and every custom sticker
1000
+ degraded to a `[表情]` placeholder.
1001
+
1002
+ Returns (seed, discovered). The scan costs a few seconds and is only
1003
+ needed once: the result is memoised under the output directory, and
1004
+ persisting it via `weflow-cli config set emoticonSeed` skips even that.
1005
+ """
1006
+ if configured:
1007
+ return str(configured), False
1008
+ if not account_dir or not _WECHAT_EMOTICON:
1009
+ return '', False
1010
+
1011
+ cache_dir = os.path.join(out_dir, '.sticker-cache')
1012
+ memo = os.path.join(cache_dir, 'seed')
1013
+ try:
1014
+ with open(memo, 'r', encoding='utf-8') as fh:
1015
+ memoised = fh.read().strip()
1016
+ if memoised:
1017
+ return memoised, False
1018
+ except OSError:
1019
+ pass
1020
+
1021
+ dirs = wechat_emoticon.sticker_cache_dirs(account_dir)
1022
+ sample = wechat_emoticon.any_sticker_file(dirs)
1023
+ if not sample:
1024
+ return '', False
1025
+ wxid = wechat_emoticon.account_wxid(os.path.basename(os.path.normpath(account_dir)))
1026
+ seed = wechat_emoticon.find_seed(wxid, sample)
1027
+ if not seed:
1028
+ return '', False
1029
+ try:
1030
+ os.makedirs(cache_dir, exist_ok=True)
1031
+ with open(memo, 'w', encoding='utf-8') as fh:
1032
+ fh.write(str(seed))
1033
+ except OSError:
1034
+ pass
1035
+ return str(seed), True
1036
+
1037
+
1038
+ def _page_cache_file(page_url):
1039
+ """Cache path for a resolved share-page cover.
1040
+
1041
+ Namespaced apart from download_image_as_base64's entries so a page URL and
1042
+ an image URL can never collide on the same digest.
1043
+ """
1044
+ cache_dir = COVER_STATE.get('dir') or ''
1045
+ if not cache_dir:
1046
+ return ''
1047
+ return os.path.join(cache_dir, 'page-' + hashlib.md5(page_url.encode()).hexdigest() + '.b64')
1048
+
1049
+
1050
+ def download_bilibili_cover(page_url, timeout=10):
1051
+ """Resolve a Bilibili share page and embed its og:image cover.
1052
+
1053
+ Guarded like download_page_og_image: Bilibili shares are common in chat,
1054
+ and without a cache + shared budget every re-export re-fetched all of
1055
+ them, which alone accounted for minutes of an export.
1056
+ """
1057
+ if not page_url or not re.match(
1058
+ r'https?://(?:www\.)?(?:b23\.tv|bilibili\.com)(?:/|$)',
1059
+ page_url, re.IGNORECASE):
1060
+ return None
1061
+
1062
+ sink = PREFETCH['sink']
1063
+ if sink is not None:
1064
+ sink.append(('page', page_url, ''))
1065
+ return None
1066
+
1067
+ cache_file = _page_cache_file(page_url)
1068
+ cached = _read_cache_media(cache_file)
1069
+ if cached != 'unknown':
1070
+ COVER_STATE['cached'] += 1
1071
+ return cached
1072
+ if not PREFETCH['active']:
1073
+ # The prefetch pass has PREFETCH_MAX_URLS as its own bound and runs
1074
+ # concurrently, so charging it here would only make a large
1075
+ # conversation hit the cap early and fall back to serial fetching.
1076
+ if COVER_STATE.get('budget', 0) <= 0:
1077
+ COVER_STATE['skipped'] += 1
1078
+ return None
1079
+ COVER_STATE['budget'] -= 1
1080
+ COVER_STATE['fetched'] += 1
1081
+
1082
+ try:
1083
+ req = urllib.request.Request(page_url, headers={
1084
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
1085
+ 'Referer': 'https://www.bilibili.com/',
1086
+ })
1087
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1088
+ html = resp.read(COVER_HEAD_BYTES).decode('utf-8', errors='ignore')
1089
+ final_url = resp.geturl()
1090
+ match = re.search(
1091
+ r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)',
1092
+ html, re.IGNORECASE,
1093
+ )
1094
+ cover_url = decode_xml(match.group(1)).replace('\\/', '/').strip() if match else ''
1095
+ if not cover_url:
1096
+ bvid_match = re.search(r'/(BV[0-9A-Za-z]+)(?:/|\?|$)', final_url, re.IGNORECASE)
1097
+ if not bvid_match:
1098
+ return None
1099
+ api_url = 'https://api.bilibili.com/x/web-interface/view?bvid=' + bvid_match.group(1)
1100
+ api_req = urllib.request.Request(api_url, headers={
1101
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
1102
+ 'Referer': final_url,
1103
+ })
1104
+ with urllib.request.urlopen(api_req, timeout=timeout) as api_resp:
1105
+ payload = json.loads(api_resp.read(1024 * 1024).decode('utf-8'))
1106
+ cover_url = str((payload.get('data') or {}).get('pic') or '')
1107
+ if not cover_url:
1108
+ _cache_media(cache_file, None)
1109
+ return None
1110
+ cover = download_image_as_base64(cover_url, timeout=timeout, budgeted=False)
1111
+ _cache_media(cache_file, cover)
1112
+ return cover
1113
+ except Exception:
1114
+ _cache_media(cache_file, None)
1115
+ return None
1116
+
1117
+
1118
+ def download_page_og_image(page_url, timeout=10):
1119
+ """Fetch a share page's og:image and embed the resolved cover.
1120
+
1121
+ Guarded by a disk cache and a per-run budget: without them a conversation
1122
+ full of links spends minutes on network round-trips, and every re-export
1123
+ repeats the whole cost.
1124
+ """
1125
+ if not page_url or not page_url.startswith(('http://', 'https://')):
1126
+ return None
1127
+
1128
+ sink = PREFETCH['sink']
1129
+ if sink is not None:
1130
+ sink.append(('page', page_url, ''))
1131
+ return None
1132
+
1133
+ cache_file = _page_cache_file(page_url)
1134
+ cached = _read_cache_media(cache_file)
1135
+ if cached != 'unknown':
1136
+ COVER_STATE['cached'] += 1
1137
+ return cached
1138
+
1139
+ if not PREFETCH['active']:
1140
+ # The prefetch pass has PREFETCH_MAX_URLS as its own bound and runs
1141
+ # concurrently, so charging it here would only make a large
1142
+ # conversation hit the cap early and fall back to serial fetching.
1143
+ if COVER_STATE.get('budget', 0) <= 0:
1144
+ COVER_STATE['skipped'] += 1
1145
+ return None
1146
+ COVER_STATE['budget'] -= 1
1147
+ COVER_STATE['fetched'] += 1
1148
+
1149
+ try:
1150
+ req = urllib.request.Request(page_url, headers={
1151
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
1152
+ 'Referer': page_url,
1153
+ })
1154
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1155
+ html = resp.read(COVER_HEAD_BYTES).decode('utf-8', errors='ignore')
1156
+ patterns = (
1157
+ r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)',
1158
+ r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image',
1159
+ r'<meta[^>]+name=["\']twitter:image["\'][^>]+content=["\']([^"\']+)',
1160
+ )
1161
+ cover_url = ''
1162
+ for pattern in patterns:
1163
+ match = re.search(pattern, html, re.IGNORECASE)
1164
+ if match:
1165
+ cover_url = decode_xml(match.group(1)).replace('\\/', '/').strip()
1166
+ break
1167
+ cover = download_image_as_base64(cover_url, timeout=timeout, budgeted=False) if cover_url else None
1168
+ _cache_media(cache_file, cover)
1169
+ return cover
1170
+ except Exception:
1171
+ _cache_media(cache_file, None)
1172
+ return None
1173
+
1174
+
1175
+ def prefetch_remote(records):
1176
+ """Fetch recorded remote media concurrently, filling the disk cache.
1177
+
1178
+ `records` is the PREFETCH sink: (kind, url, aes_key) triples. Everything
1179
+ lands in the same cache `download_image_as_base64` reads, so the real
1180
+ formatting pass becomes a sequence of cache hits. Returns a short summary
1181
+ for the progress line.
1182
+ """
1183
+ seen = set()
1184
+ jobs = []
1185
+ for kind, url, aes_key in records:
1186
+ key = (kind, url, aes_key)
1187
+ if key in seen:
1188
+ continue
1189
+ seen.add(key)
1190
+ jobs.append(key)
1191
+ if not jobs:
1192
+ return 0, 0
1193
+
1194
+ dropped = 0
1195
+ if len(jobs) > PREFETCH_MAX_URLS:
1196
+ dropped = len(jobs) - PREFETCH_MAX_URLS
1197
+ jobs = jobs[:PREFETCH_MAX_URLS]
1198
+
1199
+ def run(job):
1200
+ kind, url, aes_key = job
1201
+ try:
1202
+ if kind == 'page':
1203
+ if download_bilibili_cover(url):
1204
+ return True
1205
+ return download_page_og_image(url) is not None
1206
+ return download_image_as_base64(url, aes_key) is not None
1207
+ except Exception:
1208
+ return False
1209
+
1210
+ done = 0
1211
+ PREFETCH['active'] = True
1212
+ try:
1213
+ with concurrent.futures.ThreadPoolExecutor(max_workers=PREFETCH_WORKERS) as pool:
1214
+ for ok in pool.map(run, jobs):
1215
+ if ok:
1216
+ done += 1
1217
+ finally:
1218
+ PREFETCH['active'] = False
1219
+ return done, dropped
1220
+
1221
+
1222
+ def extract_xml_attr_url(content, name):
1223
+ match = re.search(rf'\b{name}\s*=\s*["\']([^"\']+)', str(content or ''), re.IGNORECASE)
1224
+ if not match:
1225
+ return None
1226
+ url = decode_xml(match.group(1)).replace('\\/', '/').replace('*#*', ':').strip()
1227
+ return url if url.startswith(('http://', 'https://')) else None
1228
+
1229
+
1230
+ def extract_xml_attr_value(content, name):
1231
+ match = re.search(rf'\b{name}\s*=\s*["\']([^"\']*)', str(content or ''), re.IGNORECASE)
1232
+ return decode_xml(match.group(1).strip()) if match else ''
1233
+
1234
+
1235
+ def render_contact_card(content):
1236
+ nickname = extract_xml_attr_value(content, 'nickname') or '公众号名片'
1237
+ username = extract_xml_attr_value(content, 'username')
1238
+ avatar_url = extract_xml_attr_url(content, 'brandIconUrl')
1239
+ avatar = download_image_as_base64(avatar_url) if avatar_url else None
1240
+ parts = []
1241
+ if avatar:
1242
+ b64, mime = avatar
1243
+ parts.append(f'<img class="msg-app-thumb" src="data:{mime};base64,{b64}" loading="lazy" />')
1244
+ parts.append(f'<span class="msg-app-title">{escape_html(nickname)}</span>')
1245
+ if username:
1246
+ parts.append(f'<div class="msg-app-desc">{escape_html(username)}</div>')
1247
+ return '<div class="msg-app">' + ''.join(parts) + '</div>'
1248
+
1249
+
1250
+ def load_contact_names(db_path, key_hex, salt_hex):
1251
+ """{username: best available name} from the contact database.
1252
+
1253
+ A group message only identifies its sender by wxid, which is unusable in a
1254
+ transcript. Prefer the remark (what the user calls them) over the account
1255
+ nickname over the alias, matching how the 1:1 path already resolves the
1256
+ conversation partner.
1257
+ """
1258
+ names = {}
1259
+ if not db_path or not key_hex or not salt_hex or not os.path.isfile(db_path):
1260
+ return names
1261
+ conn = None
1262
+ try:
1263
+ conn, cursor = connect(db_path, key_hex, salt_hex)
1264
+ cursor.execute('SELECT username, remark, nick_name, alias FROM contact')
1265
+ for username, remark, nick_name, alias in cursor.fetchall():
1266
+ if not username:
1267
+ continue
1268
+ names[username] = remark or nick_name or alias or username
1269
+ except Exception:
1270
+ return names
1271
+ finally:
1272
+ if conn is not None:
1273
+ conn.close()
1274
+ return names
1275
+
1276
+
1277
+ def contact_name(wxid):
1278
+ """Display name for a wxid, falling back to the wxid itself.
1279
+
1280
+ An unresolved id is kept rather than blanked: it is still unique, and a
1281
+ transcript that silently drops the speaker is worse than one showing one.
1282
+ """
1283
+ return CONTACT_NAMES.get(wxid) or wxid
1284
+
1285
+
1286
+ def split_group_speaker(content, sender_map, own_wxid=''):
1287
+ """(speaker id, content) with the group sender prefix removed.
1288
+
1289
+ Group rows generally prefix the content with the speaker's id. Only an id
1290
+ the sender map actually knows is accepted, so a message that merely starts
1291
+ with `note: ...` is not mistaken for one. Returns (None, content) when
1292
+ there is no prefix to strip.
1293
+ """
1294
+ match = re.match(r'^([A-Za-z0-9_@.-]{5,64})\s*[::]\s', str(content or ''))
1295
+ if not match:
1296
+ return None, content
1297
+ candidate = match.group(1)
1298
+ if candidate not in set((sender_map or {}).values()):
1299
+ return None, content
1300
+ return candidate, content[match.end():]
1301
+
1302
+
1303
+ def image_is_blank(b64_data, threshold=0.985):
1304
+ """True for an embedded payload that carries no visible artwork.
1305
+
1306
+ Sticker sources occasionally hand back an all-white frame - a truncated
1307
+ local download, or a CDN placeholder. Embedding it renders as an empty
1308
+ square, which reads as a broken export rather than as the placeholder it
1309
+ is, so callers prefer the text fallback.
1310
+ """
1311
+ if not b64_data or not _WECHAT_IMAGE:
1312
+ return False
1313
+ try:
1314
+ from collections import Counter
1315
+ import io as _io
1316
+ from PIL import Image
1317
+ with Image.open(_io.BytesIO(base64.b64decode(b64_data))) as image:
1318
+ # An animation's first frame can be blank while the animation is
1319
+ # not; judging it on frame 1 would drop a perfectly good sticker.
1320
+ if getattr(image, 'is_animated', False):
1321
+ return False
1322
+ pixels = list(image.convert('RGB').resize((32, 32)).getdata())
1323
+ if not pixels:
1324
+ return True
1325
+ return Counter(pixels).most_common(1)[0][1] / len(pixels) > threshold
1326
+ except Exception:
1327
+ return False
1328
+
1329
+
1330
+ def render_voice(local_id, content):
1331
+ """`[语音 6″]` plus its transcript when one has been cached.
1332
+
1333
+ Browsers cannot play SILK and ffmpeg cannot decode it, so there is no audio
1334
+ element to offer - the transcript is the only way a voice message can carry
1335
+ meaning in an export.
1336
+ """
1337
+ length = (extract_xml_attr_value(content, 'voicelength')
1338
+ or extract_xml_attr_value(content, 'length'))
1339
+ seconds = f' {round(int(length) / 1000)}″' if length.isdigit() and int(length) > 0 else ''
1340
+ label = f'<span class="msg-media">[语音{seconds}]</span>'
1341
+ blob = VOICE_STATE['map'].get(local_id)
1342
+ cache = VOICE_STATE['cache']
1343
+ if blob is None or cache is None:
1344
+ return label
1345
+ text = cache.get(wechat_voice.voice_key(blob))
1346
+ if not text:
1347
+ return label
1348
+ # Labelled, not presented as the words themselves. Recognition of dialect
1349
+ # speech is approximate: an unlabelled transcript reads as a quote, and a
1350
+ # confidently wrong quote is worse than an obvious placeholder.
1351
+ return (f'{label}<div class="msg-voice-text">'
1352
+ f'<span class="voice-tag">机器转写·粤语欠准</span>{escape_html(text)}</div>')
1353
+
1354
+
1355
+ def render_location(content):
1356
+ """Readable label for a type-48 location row instead of its raw XML."""
1357
+ label = extract_xml_attr_value(content, 'poiname') or extract_xml_attr_value(content, 'label')
1358
+ return f'[位置] {escape_html(label)}' if label else '<span class="msg-media">[位置]</span>'
1359
+
1360
+
1361
+ def extract_xml_text(content, tag):
1362
+ """Extract plain or CDATA-wrapped text from one XML element."""
1363
+ if not content:
1364
+ return ''
1365
+ match = re.search(rf'<{tag}\b[^>]*>([\s\S]*?)</{tag}>', content, re.IGNORECASE)
1366
+ if not match:
1367
+ return ''
1368
+ value = match.group(1).strip()
1369
+ cdata = re.fullmatch(r'<!\[CDATA\[([\s\S]*)\]\]>', value)
1370
+ return decode_xml((cdata.group(1) if cdata else value).strip())
1371
+
1372
+
1373
+ def extract_media_aes_key(content):
1374
+ if not content:
1375
+ return ''
1376
+ for name in ('aeskey', 'aes_key', 'encryptaeskey'):
1377
+ match = re.search(rf'\b{name}\s*=\s*["\']([0-9a-f]{{32}})', content, re.IGNORECASE)
1378
+ if match:
1379
+ return match.group(1)
1380
+ match = re.search(rf'<{name}\b[^>]*>\s*([0-9a-f]{{32}})\s*</{name}>', content, re.IGNORECASE)
1381
+ if match:
1382
+ return match.group(1)
1383
+ return ''
1384
+
1385
+
1386
+ def plain_fragment(text, limit=200):
1387
+ """Tag-stripped, whitespace-collapsed preview of an XML fragment."""
1388
+ if not text:
1389
+ return ''
1390
+ text = re.sub(r'<\?xml[^>]*\?>', ' ', str(text))
1391
+ text = re.sub(r'<[^>]*>', ' ', text)
1392
+ text = re.sub(r'\s+', ' ', decode_xml(text)).strip()
1393
+ return text[:limit]
1394
+
1395
+
1396
+ def readable_fragment(text, limit=300):
1397
+ """`text` with any embedded document reduced to prose.
1398
+
1399
+ Quoted replies (appmsg type 57) put a whole escaped message inside <des>,
1400
+ so rendering it verbatim fills the bubble with markup. Plain text passes
1401
+ through unchanged.
1402
+ """
1403
+ if not text:
1404
+ return ''
1405
+ if '<' in text or '&lt;' in text:
1406
+ return plain_fragment(decode_xml(text), limit)
1407
+ return text[:limit]
1408
+
1409
+
1410
+ def render_group_template(content):
1411
+ """Readable text for a `sysmsgtemplate` group notice, or ''.
1412
+
1413
+ The row carries a sentence template with `$name$` placeholders plus a
1414
+ `link_list` that maps each placeholder to member nicknames. Stripping tags
1415
+ without substituting left nothing but the chatroom id.
1416
+ """
1417
+ template = extract_xml_text(content, 'template')
1418
+ if not template:
1419
+ return ''
1420
+ for placeholder in set(re.findall(r'\$(\w+)\$', template)):
1421
+ link = re.search(rf'<link\s+name="{placeholder}"[\s\S]*?</link>', content, re.IGNORECASE)
1422
+ if not link:
1423
+ continue
1424
+ nicknames = []
1425
+ for raw in re.findall(r'<nickname>([\s\S]*?)</nickname>', link.group(0), re.IGNORECASE):
1426
+ value = raw.strip()
1427
+ cdata = re.fullmatch(r'<!\[CDATA\[([\s\S]*)\]\]>', value)
1428
+ text = (cdata.group(1) if cdata else value).strip()
1429
+ if text:
1430
+ nicknames.append(decode_xml(text))
1431
+ separator = extract_xml_text(link.group(0), 'separator') or '、'
1432
+ template = template.replace(f'${placeholder}$', separator.join(nicknames))
1433
+ # Any placeholder with no link_list entry is left dangling; drop it rather
1434
+ # than print `$username$` into the transcript.
1435
+ return re.sub(r'\$\w+\$', '', template).strip()
1436
+
1437
+
1438
+ def render_system_message(content, names=None):
1439
+ """Readable text for a type-10000 system row.
1440
+
1441
+ These rows carry XML, but it is WeChat's own display markup
1442
+ (`<img src="SystemMessages_HongbaoIcon.png"/>`, `<_wc_custom_link_ ...>`)
1443
+ rather than a document worth showing. Escaping it verbatim put a wall of
1444
+ `&lt;sysmsg ...&gt;` in the bubble; a revoke notice read as XML instead of
1445
+ saying who revoked what.
1446
+ """
1447
+ if not content:
1448
+ return ''
1449
+ if '<' not in content:
1450
+ return escape_html(content)
1451
+ if '<sysmsgtemplate' in content:
1452
+ # A join/invite notice: a template plus a member list, which has to be
1453
+ # substituted before any of the generic extraction below can help.
1454
+ return escape_html(render_group_template(content)) or escape_html(plain_fragment(content, 200))
1455
+ revoke = re.search(r'<revokemsg\b[\s\S]*?</revokemsg>', content, re.IGNORECASE)
1456
+ scope = revoke.group(0) if revoke else content
1457
+ text = ''
1458
+ for tag in ('content', 'title', 'text'):
1459
+ text = extract_xml_text(scope, tag)
1460
+ if text:
1461
+ break
1462
+ if not text:
1463
+ text = plain_fragment(scope, 200)
1464
+ for wxid, name in (names or {}).items():
1465
+ # The row keeps `$wxid_...$` for the client to expand at render time.
1466
+ if wxid and name:
1467
+ text = text.replace(f'${wxid}$', name)
1468
+ return escape_html(text)
1469
+
1470
+
264
1471
  def escape_html(text):
265
1472
  if not text:
266
1473
  return ''
267
- return (str(text)
1474
+ text = ''.join(char for char in str(text) if char in '\n\r\t' or ord(char) >= 32)
1475
+ return (text
268
1476
  .replace('&', '&amp;')
269
1477
  .replace('<', '&lt;')
270
1478
  .replace('>', '&gt;')
271
1479
  .replace('"', '&quot;'))
272
1480
 
273
1481
 
1482
+ def load_builtin_emoji(name):
1483
+ """One bundled face as (base64, mime), or None."""
1484
+ path = wechat_emoji.IMAGE_FACES.get(name) if _WECHAT_EMOJI else None
1485
+ if not path:
1486
+ return None
1487
+ try:
1488
+ with open(path, 'rb') as stream:
1489
+ data = stream.read(MAX_EMBED_SIZE + 1)
1490
+ if len(data) > MAX_EMBED_SIZE:
1491
+ return None
1492
+ return base64.b64encode(data).decode(), 'image/png'
1493
+ except OSError:
1494
+ return None
1495
+
1496
+
1497
+ def render_builtin_emoji(content, label=None):
1498
+ """Render every built-in face in `content`, in place.
1499
+
1500
+ Faces become <span class="wxface wxf-…">; the artwork itself is emitted
1501
+ once per page by face_css(). The previous approach appended one <img> per
1502
+ message and only ever matched the first label, so a sentence containing
1503
+ several emoji rendered at most one - and repeated the base64 for it.
1504
+ """
1505
+ if not _WECHAT_EMOJI:
1506
+ return escape_html(content)
1507
+ return wechat_emoji.render_faces(escape_html(str(content or '')))
1508
+
1509
+
274
1510
  def parse_source(source_text):
275
1511
  """Parse source field to extract sender and content."""
276
1512
  sender = ''
@@ -297,7 +1533,19 @@ def parse_source(source_text):
297
1533
  return sender, content
298
1534
 
299
1535
 
300
- def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display_name=''):
1536
+ def sender_matches_account(sender_user_name, own_wxid):
1537
+ """Match the sender against the configured account, including NT suffixes."""
1538
+ if not sender_user_name or not own_wxid:
1539
+ return False
1540
+ if sender_user_name == own_wxid or sender_user_name.startswith(own_wxid + '_'):
1541
+ return True
1542
+ account_base = own_wxid.rsplit('_', 1)
1543
+ if len(account_base) == 2 and len(account_base[1]) == 4 and account_base[1].isalnum():
1544
+ return sender_user_name == account_base[0]
1545
+ return False
1546
+
1547
+
1548
+ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display_name='', resource_map=None, own_wxid=''):
301
1549
  """Format a single message for HTML display.
302
1550
 
303
1551
  Args:
@@ -308,47 +1556,120 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
308
1556
  sender_map: {sender_id: user_name} from Name2Id table
309
1557
  display_name: human-readable name for the target talker
310
1558
  """
1559
+ is_group = '@chatroom' in str(talker or '')
311
1560
  local_id = row[0] or 0
1561
+ server_id = row[1] or 0
312
1562
  local_type = row[2] or 0
1563
+ if local_type > 0xffffffff:
1564
+ local_type &= 0xffffffff
313
1565
  real_sender_id = row[4] or 0
314
1566
  create_time = row[5] or 0
315
1567
  source = row[7]
316
1568
  message_content = row[8]
1569
+ compressed_content = row[9]
1570
+ resource_md5s = list((resource_map or {}).get(f'server:{int(server_id)}', [])) if server_id else []
317
1571
 
318
1572
  # Resolve sender name
319
1573
  sender_user_name = (sender_map or {}).get(real_sender_id, '')
320
- is_self = (sender_user_name != talker) # not the target talker = sent by me
321
-
322
- # Build display sender name
323
- if is_self:
324
- sender_display = '我'
325
- elif display_name:
326
- sender_display = display_name
327
- elif sender_user_name:
328
- sender_display = sender_user_name
329
- else:
330
- sender_display = talker
1574
+ # Do not infer "self" from a missing mapping. NT shards can have incomplete
1575
+ # Name2Id rows, and that would otherwise mark every unresolved message as sent.
1576
+ is_self = sender_matches_account(sender_user_name, own_wxid)
1577
+ if not is_self and sender_user_name and not own_wxid:
1578
+ is_self = sender_user_name != talker
331
1579
 
332
1580
  # Get content
333
1581
  content = ''
334
1582
  if isinstance(message_content, str) and message_content:
335
1583
  content = message_content
336
1584
  elif isinstance(message_content, bytes):
337
- try:
338
- content = message_content.decode('utf-8', errors='ignore')
339
- except:
340
- pass
1585
+ content = decode_message_content(message_content)
1586
+
1587
+ if not content and isinstance(compressed_content, bytes):
1588
+ content = decode_message_content(compressed_content)
1589
+
1590
+ source_text = decode_message_content(source) if isinstance(source, (bytes, bytearray, memoryview)) else str(source or '')
1591
+ if not content and source_text:
1592
+ _, content = parse_source(source_text)
1593
+ # Some NT rows store the complete emoji XML entity-escaped in the message
1594
+ # column (for example ``&lt;msg&gt;...&lt;/msg&gt;``). Normalize it before
1595
+ # detecting media metadata so it follows the same path as raw XML.
1596
+ if '&lt;' in content.lower():
1597
+ normalized_content = content
1598
+ for _ in range(2):
1599
+ candidate = decode_xml(normalized_content)
1600
+ if candidate == normalized_content:
1601
+ break
1602
+ normalized_content = candidate
1603
+ if re.search(r'<(?:msg|emoji)\b', normalized_content, re.IGNORECASE):
1604
+ content = normalized_content
1605
+ # NT emoji metadata may be split between source XML and message_content.
1606
+ # Prefer the representation that actually carries media identity/URLs;
1607
+ # source can contain only PUA/signature fields for the same message.
1608
+ metadata_parts = []
1609
+ if source_text:
1610
+ metadata_parts.append(source_text)
1611
+ if content and content != source_text:
1612
+ metadata_parts.append(content)
1613
+ metadata_content = '\n'.join(metadata_parts)
1614
+ is_emoji_xml = bool(re.search(r'<(?:msg\s*>)?\s*<emoji\b|<emoji\b', metadata_content, re.IGNORECASE))
1615
+ is_contact_card = bool(
1616
+ re.search(r'<msg\b[^>]*(?:nickname|username)=', metadata_content, re.IGNORECASE)
1617
+ and re.search(r'\bbrandIconUrl=', metadata_content, re.IGNORECASE)
1618
+ )
1619
+ has_builtin_signature = bool(
1620
+ re.search(r'<signature\b[^>]*>[^<]+</signature>', metadata_content, re.IGNORECASE)
1621
+ and _face_index.has_face(content)
1622
+ )
1623
+ builtin_emoji_label = _face_index.find_face(content)
1624
+
1625
+ # A group row's `display_name` is the group, not the speaker, so using it
1626
+ # put the same name on every bubble and nobody could tell who said what.
1627
+ # The speaker is in the content prefix; fall back to the sender map.
1628
+ group_speaker = None
1629
+ if is_group:
1630
+ group_speaker, content = split_group_speaker(content, sender_map)
1631
+
1632
+ if local_type == 10000:
1633
+ # Revoke notices and group templates are emitted by the system, and
1634
+ # their real_sender_id resolves to nobody. Anything else here reads as
1635
+ # if a person had said it.
1636
+ sender_display = '系统'
1637
+ elif is_self:
1638
+ sender_display = '我'
1639
+ elif group_speaker:
1640
+ sender_display = ('我' if sender_matches_account(group_speaker, own_wxid)
1641
+ else contact_name(group_speaker))
1642
+ elif is_group and sender_user_name:
1643
+ sender_display = contact_name(sender_user_name)
1644
+ elif display_name and not is_group:
1645
+ # `display_name` is the conversation. For a group that is the group
1646
+ # itself, so using it as a fallback labelled unrelated rows with the
1647
+ # group name - three system rows in one export read as "江南试验群".
1648
+ sender_display = display_name
1649
+ elif sender_user_name:
1650
+ sender_display = contact_name(sender_user_name)
1651
+ else:
1652
+ sender_display = '未知发送者'
341
1653
 
342
- if not content and isinstance(source, str):
343
- _, content = parse_source(source)
1654
+ if '\x00' in content or sum(ord(char) < 32 and char not in '\n\r\t' for char in content) > 2:
1655
+ content = ''
344
1656
 
345
1657
  # Determine display content
346
1658
  display = ''
347
1659
  image_b64 = None
348
1660
 
349
- if local_type == 1:
1661
+ if is_contact_card:
1662
+ display = render_contact_card(metadata_content)
1663
+ elif local_type == 1 and '<' not in content:
1664
+ # Tested against `content`, not `metadata_content`: the latter always
1665
+ # carries the row's `<msgsource>` block, so it always contains '<'.
1666
+ # Any text message with sender metadata therefore failed this branch
1667
+ # and fell through to the emoji one, which rendered it as `[表情]` plus
1668
+ # whatever URL its catch-all found - a meeting invite became a broken
1669
+ # image whose src was `https://meeting.tencent.com/dm/...`.
350
1670
  # Text
351
- display = escape_html(content)
1671
+ builtin_label = _face_index.find_face(content)
1672
+ display = render_builtin_emoji(content, builtin_label) if builtin_label else escape_html(content)
352
1673
  elif local_type == 3:
353
1674
  # Image - try cache map first, then traditional FileStorage
354
1675
  display = '<span class="msg-media">[图片]</span>'
@@ -356,58 +1677,140 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
356
1677
  mime = 'image/jpeg'
357
1678
 
358
1679
  # Priority 1: NT cache thumbnails
359
- if image_map and local_id in image_map:
360
- img_data, mime = image_map[local_id]
1680
+ cached = get_cached_image(image_map, local_id, create_time, metadata_content, resource_md5s)
1681
+ if cached:
1682
+ img_data, mime = cached
361
1683
  # Priority 2: Traditional FileStorage
362
1684
  else:
363
1685
  result = find_thumbnail(create_time, local_id, wx_dir)
364
1686
  if result:
365
1687
  img_data, mime = result
1688
+ else:
1689
+ thumb_url = extract_appmsg_image(content)
1690
+ downloaded = download_image_as_base64(thumb_url, extract_media_aes_key(content)) if thumb_url else None
1691
+ if downloaded:
1692
+ img_data, mime = downloaded
366
1693
 
367
1694
  if img_data:
368
1695
  image_b64 = img_data
369
1696
  display += f'<br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
370
- elif image_map and local_id in image_map:
1697
+ elif local_type not in MSG_TYPES and get_cached_image(image_map, local_id, create_time, content, resource_md5s):
371
1698
  # Some image messages use encoded types (e.g. 21474836529 = images in appmsg)
372
1699
  # Check image_map for any message type
373
- img_data, mime = image_map[local_id]
1700
+ img_data, mime = get_cached_image(image_map, local_id, create_time, content, resource_md5s)
374
1701
  if img_data:
375
1702
  image_b64 = img_data
376
1703
  display = f'<span class="msg-media">[图片]</span><br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
377
1704
  elif local_type == 34:
378
- display = '<span class="msg-media">[语音]</span>'
1705
+ display = render_voice(local_id, metadata_content or content)
379
1706
  elif local_type == 43:
380
- display = '<span class="msg-media">[视频]</span>'
381
- elif local_type == 47:
382
- display = escape_html(content) if content else '<span class="msg-media">[表情]</span>'
383
- elif local_type == 49:
1707
+ # A video row has no frame of its own, but a poster image can exist
1708
+ # under the same md5, so show it when the index has one.
1709
+ seconds = extract_xml_attr_value(metadata_content, 'playlength')
1710
+ label = f'[视频 {seconds}″]' if seconds.isdigit() and seconds != '0' else '[视频]'
1711
+ display = f'<span class="msg-media">{label}</span>'
1712
+ cached = get_cached_image(image_map, local_id, create_time, metadata_content, resource_md5s)
1713
+ if cached:
1714
+ img_data, mime = cached
1715
+ image_b64 = img_data
1716
+ display += f'<br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
1717
+ elif local_type == 48:
1718
+ display = render_location(metadata_content or content)
1719
+ elif is_emoji_xml or (local_type in (1, 47) and ('<' in metadata_content or local_type == 47)):
1720
+ emoji_label = content if content.startswith('[') and content.endswith(']') else '[表情]'
1721
+ cached = get_cached_image(image_map, local_id, create_time, metadata_content, resource_md5s)
1722
+ if cached:
1723
+ img_data, mime = cached
1724
+ image_b64 = img_data
1725
+ display = f'<span class="msg-media">{escape_html(emoji_label)}</span><br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
1726
+ else:
1727
+ # Local first. WeChat's own sticker cache is offline, instant, and
1728
+ # normally resolves; the CDN paths need a network round-trip each
1729
+ # and usually have nothing left to serve for older stickers.
1730
+ if _WECHAT_EMOTICON and STICKER_STATE['key'] and metadata_content:
1731
+ sticker_md5 = extract_xml_attr_value(metadata_content, 'md5') or ''
1732
+ data, mime = wechat_emoticon.load_sticker(
1733
+ STICKER_STATE['dirs'], sticker_md5, STICKER_STATE['key'],
1734
+ STICKER_STATE['cache_dir'])
1735
+ if data:
1736
+ img_data = base64.b64encode(data).decode()
1737
+ image_b64 = img_data
1738
+ display = f'<span class="msg-media">{escape_html(emoji_label)}</span><br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
1739
+ thumb_url = ''
1740
+ if not image_b64:
1741
+ thumb_url = extract_appmsg_image(metadata_content)
1742
+ downloaded = download_image_as_base64(thumb_url, extract_media_aes_key(metadata_content)) if thumb_url else None
1743
+ if not downloaded:
1744
+ fallback_url = extract_xml_attr_url(metadata_content, 'thumburl')
1745
+ if fallback_url and fallback_url != thumb_url:
1746
+ downloaded = download_image_as_base64(fallback_url)
1747
+ if downloaded:
1748
+ img_data, mime = downloaded
1749
+ image_b64 = img_data
1750
+ display = f'<span class="msg-media">{escape_html(emoji_label)}</span><br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
1751
+ if image_b64 and image_is_blank(image_b64):
1752
+ # A blank square is indistinguishable from a broken page; fall
1753
+ # back to the label so the message still reads as an emoji.
1754
+ image_b64 = None
1755
+ img_data = None
1756
+ display = f'<span class="msg-media">{escape_html(emoji_label)}</span>'
1757
+ if image_b64:
1758
+ pass
1759
+ elif has_builtin_signature and builtin_emoji_label:
1760
+ display = render_builtin_emoji(content, builtin_emoji_label)
1761
+ else:
1762
+ # Forwarded/default emoji messages can contain a complete
1763
+ # appmsg XML wrapper but no recoverable local media. Keep the
1764
+ # export readable instead of dumping the XML into the bubble.
1765
+ if is_emoji_xml:
1766
+ title = extract_xml_text(content, 'title')
1767
+ display = escape_html(title or emoji_label)
1768
+ elif is_share_page_url(content.strip()):
1769
+ # A webpage URL carried by an emoji-like row is still a
1770
+ # link, not an image. Preserve the original content.
1771
+ display = escape_html(content)
1772
+ else:
1773
+ display = escape_html(content) if content else '<span class="msg-media">[表情]</span>'
1774
+ elif local_type == 49 and not is_emoji_xml:
384
1775
  # App message (link/file/article)
385
1776
  if content:
386
1777
  # Try to parse XML for title/desc
387
- title_m = re.search(r'<title>([^<]*)</title>', content)
388
- desc_m = re.search(r'<des>([^<]*)</des>', content)
389
- url_m = re.search(r'<url>([^<]*)</url>', content)
390
- type_m = re.search(r'<type>(\d+)</type>', content)
391
- fname_m = re.search(r'<title>([^<]+\.\w+)</title>', content)
392
-
393
- if type_m and type_m.group(1) == '6' and fname_m:
394
- display = f'<span class="msg-file">[文件] {escape_html(fname_m.group(1))}</span>'
395
- elif title_m:
1778
+ title = readable_fragment(extract_xml_text(content, 'title'))
1779
+ desc = readable_fragment(extract_xml_text(content, 'des'))
1780
+ url = extract_xml_text(content, 'url')
1781
+ app_type = extract_xml_text(content, 'type')
1782
+
1783
+ if app_type == '6' and re.search(r'\.\w+$', title):
1784
+ display = f'<span class="msg-file">[文件] {escape_html(title)}</span>'
1785
+ elif title:
396
1786
  parts = []
1787
+ builtin_title = _face_index.find_face(title)
1788
+ title_html = (render_builtin_emoji(title, builtin_title)
1789
+ if builtin_title else escape_html(title))
397
1790
  # Extract and embed article thumbnail image
398
1791
  thumb_url = extract_appmsg_image(content)
399
1792
  if thumb_url:
400
- img_data = download_image_as_base64(thumb_url)
401
- if img_data:
402
- b64, mime = img_data
403
- image_b64 = b64
404
- parts.append(f'<img class="msg-app-thumb" src="data:{mime};base64,{b64}" loading="lazy" />')
405
- if url_m:
406
- parts.append(f'<a class="msg-link" href="{escape_html(url_m.group(1))}" target="_blank">{escape_html(decode_xml(title_m.group(1)))}</a>')
1793
+ img_data = download_image_as_base64(thumb_url, extract_media_aes_key(content))
1794
+ else:
1795
+ page_url = extract_xml_text(content, 'url')
1796
+ img_data = download_bilibili_cover(page_url)
1797
+ if not img_data:
1798
+ img_data = download_page_og_image(page_url)
1799
+ if img_data:
1800
+ b64, mime = img_data
1801
+ image_b64 = b64
1802
+ parts.append(f'<img class="msg-app-thumb" src="data:{mime};base64,{b64}" loading="lazy" />')
1803
+ # No remote-src fallback. The candidate URL came from a
1804
+ # catch-all that accepts any URL in the row, which is usually a
1805
+ # web page link (`meeting.tencent.com`, `github.com`,
1806
+ # `support.weixin.qq.com`) rather than an image; emitting it
1807
+ # produced a broken-image icon in 56 of 57 sampled cases.
1808
+ if url.startswith(('http://', 'https://')):
1809
+ parts.append(f'<a class="msg-link" href="{escape_html(url)}" target="_blank">{title_html}</a>')
407
1810
  else:
408
- parts.append(f'<span class="msg-app-title">{escape_html(decode_xml(title_m.group(1)))}</span>')
409
- if desc_m:
410
- parts.append(f'<div class="msg-app-desc">{escape_html(decode_xml(desc_m.group(1)))}</div>')
1811
+ parts.append(f'<span class="msg-app-title">{title_html}</span>')
1812
+ if desc:
1813
+ parts.append(f'<div class="msg-app-desc">{escape_html(desc)}</div>')
411
1814
  display = '<div class="msg-app">' + ''.join(parts) + '</div>'
412
1815
  else:
413
1816
  display = '<span class="msg-media">[链接/文件]</span>'
@@ -416,7 +1819,8 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
416
1819
  elif local_type == 50:
417
1820
  display = '<span class="msg-media">[语音通话]</span>'
418
1821
  elif local_type == 10000:
419
- display = f'<span class="msg-sys">{escape_html(content)}</span>'
1822
+ names = {talker: display_name or talker, own_wxid: '我'}
1823
+ display = f'<span class="msg-sys">{render_system_message(content, names)}</span>'
420
1824
  elif local_type == 10002:
421
1825
  display = escape_html(content) if content else '<span class="msg-media">[引用]</span>'
422
1826
  else:
@@ -438,6 +1842,20 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
438
1842
  }
439
1843
 
440
1844
 
1845
+ def decode_message_content(value):
1846
+ """Decode NT message content, which may be Zstandard compressed."""
1847
+ if not value:
1848
+ return ''
1849
+ try:
1850
+ import zstandard
1851
+ value = zstandard.ZstdDecompressor().decompress(value)
1852
+ except Exception:
1853
+ pass
1854
+ if isinstance(value, bytes):
1855
+ return value.decode('utf-8', errors='ignore')
1856
+ return str(value)
1857
+
1858
+
441
1859
  def decode_xml(text):
442
1860
  """Decode XML entities."""
443
1861
  return (text
@@ -448,9 +1866,15 @@ def decode_xml(text):
448
1866
  .replace('&apos;', "'"))
449
1867
 
450
1868
 
451
- def build_html_page(talker, messages_part, part_num, total_parts, display_name):
452
- """Build a single HTML page for a part."""
453
- talker_safe = talker.replace('@', '_').replace('/', '_')
1869
+ def build_html_page(talker, messages_part, part_num, total_parts, display_name, file_prefix=''):
1870
+ """Build a single HTML page for a part.
1871
+
1872
+ `file_prefix` must be the prefix the parts were actually written under.
1873
+ Deriving it from the talker instead produced links to
1874
+ `wxid_..._part2.html` next to files named after the remark, so every
1875
+ navigation link 404'd.
1876
+ """
1877
+ talker_safe = file_prefix or talker.replace('@', '_').replace('/', '_')
454
1878
  rows = []
455
1879
  for m in messages_part:
456
1880
  dt = datetime.datetime.fromtimestamp(m['create_time'])
@@ -474,6 +1898,10 @@ def build_html_page(talker, messages_part, part_num, total_parts, display_name):
474
1898
  from_time = datetime.datetime.fromtimestamp(messages_part[0]['create_time']).strftime('%Y-%m-%d %H:%M')
475
1899
  to_time = datetime.datetime.fromtimestamp(messages_part[-1]['create_time']).strftime('%Y-%m-%d %H:%M')
476
1900
 
1901
+ # Emit artwork only for the faces this page uses; shipping the whole
1902
+ # set in every part would add ~1MB to each.
1903
+ face_rules = wechat_emoji.face_css(''.join(rows)) if _WECHAT_EMOJI else ''
1904
+
477
1905
  return f'''<!DOCTYPE html>
478
1906
  <html lang="zh-CN">
479
1907
  <head>
@@ -536,7 +1964,14 @@ body {{
536
1964
  .msg-sender {{ font-size: 11px; color: #999; margin-bottom: 3px; }}
537
1965
  .msg-content {{ font-size: 15px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }}
538
1966
  .msg-content img {{ max-width: 240px; max-height: 240px; border-radius: 4px; margin-top: 6px; display: block; }}
1967
+ .wxface {{
1968
+ display: inline-block; width: 22px; height: 22px; vertical-align: -5px;
1969
+ background-size: 22px 22px; background-repeat: no-repeat; margin: 0 1px;
1970
+ }}
1971
+ {face_rules}
539
1972
  .msg-media {{ color: #888; font-size: 14px; }}
1973
+ .msg-voice-text {{ margin-top: 4px; padding: 6px 9px; background: rgba(0,0,0,0.045); border-left: 3px solid #bbb; border-radius: 3px; font-size: 14px; line-height: 1.5; color: #444; }}
1974
+ .voice-tag {{ display: inline-block; margin-right: 6px; padding: 1px 5px; border-radius: 3px; background: #fdf0e0; color: #b5762a; font-size: 11px; vertical-align: 1px; white-space: nowrap; }}
540
1975
  .msg-sys {{ color: #bbb; font-size: 13px; }}
541
1976
  .msg-file {{ color: #07c160; font-weight: 500; }}
542
1977
  .msg-app {{ margin: 0; }}
@@ -613,7 +2048,8 @@ body {{
613
2048
  </div>
614
2049
  <div class="footer">
615
2050
  <p>Exported by WeFlow CLI · {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
616
- <p class="hint">💡 图片来自微信本地缓存,仅覆盖最近2个月。滚动查看更多聊天可生成更多缩略图。</p>
2051
+ <p class="hint">💡 图片与表情来自本机微信数据(会话缓存 + 账号媒体索引)。本机从未下载过的图片、以及未缓存封面的视频,无法显示。</p>
2052
+ <p class="hint">⚠️ 语音下方文字为<b>本机机器转写</b>,未经校对。粤语/方言的识别准确率有限,可能出现通顺但并非原话的内容——<b>不可作为原话引用</b>;需要确证时请以录音为准。</p>
617
2053
  </div>
618
2054
  <script>
619
2055
  function searchMessages(query) {{
@@ -647,59 +2083,206 @@ function searchMessages(query) {{
647
2083
  </html>'''
648
2084
 
649
2085
 
2086
+ def build_empty_html(talker, date, display_name):
2087
+ """Build a date-scoped empty page without falling back to older messages."""
2088
+ name = escape_html(display_name or talker)
2089
+ date_text = escape_html(date or '指定日期')
2090
+ return f'''<!DOCTYPE html>
2091
+ <html lang="zh-CN">
2092
+ <head>
2093
+ <meta charset="UTF-8">
2094
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2095
+ <title>聊天记录 - {name} - {date_text}</title>
2096
+ <style>
2097
+ body {{ margin:0; padding:48px 20px; background:#ededed; font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei",sans-serif; color:#666; }}
2098
+ .empty {{ max-width:720px; margin:0 auto; padding:40px 20px; text-align:center; background:#fff; border-radius:12px; box-shadow:0 1px 3px rgba(0,0,0,.08); }}
2099
+ h2 {{ margin:0 0 12px; color:#333; font-size:18px; }}
2100
+ p {{ margin:8px 0; font-size:14px; }}
2101
+ </style>
2102
+ </head>
2103
+ <body><main class="empty"><h2>{name}</h2><p>{date_text}没有消息记录</p><p>未加载其他日期的历史消息。</p></main></body>
2104
+ </html>'''
2105
+
2106
+
650
2107
  def main():
651
2108
  import argparse
652
2109
  parser = argparse.ArgumentParser(description='Export WeChat NT chat as HTML')
653
- parser.add_argument('--db', required=True, help='Path to NT database (message_0.db)')
654
- parser.add_argument('--key', required=True, help='Key hex (64 chars)')
655
- parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
656
- parser.add_argument('--talker', required=True, help='Talker username')
657
- parser.add_argument('--name', default='', help='Display name')
658
- parser.add_argument('--out', default='./output', help='Output directory')
2110
+ parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database (message_0.db)')
2111
+ parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
2112
+ parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
2113
+ parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
2114
+ parser.add_argument('--name', default=os.environ.get('WEFLOW_EXPORT_NAME', ''), help='Display name')
2115
+ parser.add_argument('--out', default=os.environ.get('WEFLOW_EXPORT_OUTPUT', './output'), help='Output directory')
659
2116
  parser.add_argument('--parts', type=int, default=5, help='Number of parts to split into')
660
2117
  parser.add_argument('--wx-dir', default='', help='Traditional WeChat data dir (FileStorage fallback)')
661
- parser.add_argument('--cache-dir', default='', help='NT cache directory for image thumbnails')
2118
+ parser.add_argument('--cache-dir', default=os.environ.get('WEFLOW_EXPORT_CACHE_DIR', ''), help='NT cache directory for image thumbnails')
2119
+ parser.add_argument('--account-dir', default=os.environ.get('WEFLOW_EXPORT_ACCOUNT_DIR', ''), help='Account data directory for media resources')
662
2120
  parser.add_argument('--single', action='store_true', help='Generate a single HTML file (no splitting)')
2121
+ parser.add_argument('--per-page', type=int, default=0,
2122
+ help='Messages per file; overrides --parts. Keeps a long history snappy to open')
2123
+ parser.add_argument('--date', default=os.environ.get('WEFLOW_EXPORT_DATE', ''), help='Only export messages from local date YYYY-MM-DD')
2124
+ parser.add_argument('--emoticon-seed', default=os.environ.get('WEFLOW_EMOTICON_SEED', ''),
2125
+ help='Account seed; decrypts custom stickers from the local cache')
2126
+ parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE', ''), help='Shared NT passphrase for deriving shard keys')
2127
+ parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID', ''), help='Configured account identifier for self-message detection')
2128
+ parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB_PATH', ''), help='Contact database, for resolving group senders to names')
2129
+ parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY', ''), help='Contact database key hex')
2130
+ parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT', ''), help='Contact database salt hex')
663
2131
  args = parser.parse_args()
664
2132
 
665
2133
  os.makedirs(args.out, exist_ok=True)
666
2134
 
667
2135
  # Scan NT cache for image thumbnails
668
2136
  image_map = {}
2137
+ _phase("start")
669
2138
  if args.cache_dir:
670
2139
  print(f"Scanning NT cache: {args.cache_dir}")
671
- image_map = scan_nt_cache(args.cache_dir, args.talker)
672
- print(f" Found {len(image_map)} cached images for embedding")
2140
+ image_map = scan_nt_cache(args.cache_dir, args.talker, args.account_dir, args.own_wxid)
2141
+ else:
2142
+ image_map = {}
2143
+ # The account media index holds everything the conversation cache does not.
2144
+ # A conversation cache only keeps recent months, so without this a group
2145
+ # photo from last year resolved 0/1444 - every image and video came out as
2146
+ # a bare `[图片]`. Both indexes are merged rather than chosen between.
2147
+ if args.account_dir:
2148
+ account_media = scan_account_media(args.account_dir, args.own_wxid, args.talker)
2149
+ print(f" Account media index: {len(account_media)} entries")
2150
+ for key, value in account_media.items():
2151
+ if key in image_map:
2152
+ continue
2153
+ # Most entries are already thumbnail-sized (median 6KB); only the
2154
+ # few large originals are worth re-encoding.
2155
+ if len(value[0]) > EMBED_SHRINK_THRESHOLD:
2156
+ value = shrink_embedded(value, max_side=720, force=True)
2157
+ image_map[key] = value
2158
+ print(f" Found {len(image_map)} media entries for embedding")
2159
+ _phase("media scan")
2160
+
2161
+ # Custom stickers: derive the local cache key from the account seed.
2162
+ if args.account_dir and _WECHAT_EMOTICON:
2163
+ STICKER_STATE['dirs'] = wechat_emoticon.sticker_cache_dirs(args.account_dir)
2164
+ STICKER_STATE['cache_dir'] = os.path.join(args.out, '.sticker-cache')
2165
+ seed, discovered = resolve_emoticon_seed(args.emoticon_seed, args.account_dir, args.out)
2166
+ if seed:
2167
+ wxid = wechat_emoticon.account_wxid(os.path.basename(os.path.normpath(args.account_dir)))
2168
+ STICKER_STATE['key'] = wechat_emoticon.derive_key(seed, wxid)
2169
+ source = 'discovered' if discovered else 'configured'
2170
+ print(f"Stickers: {len(STICKER_STATE['dirs'])} cache dir(s), seed {source}")
2171
+ if discovered:
2172
+ print(f" 建议固化以避免每次扫描: weflow-cli config set emoticonSeed {seed}")
2173
+ else:
2174
+ print("Stickers: 未找到 seed(需微信正在运行),自定义表情包将显示为 [表情]")
2175
+
2176
+ # Remote fetches: cache on disk and bound the per-run budget, otherwise
2177
+ # a link-heavy conversation spends minutes on network round-trips.
2178
+ COVER_STATE['dir'] = os.path.join(args.out, '.cover-cache')
2179
+ COVER_STATE['imgshrink'] = os.path.join(args.out, '.imgshrink-cache')
2180
+ COVER_STATE['budget'] = COVER_FETCH_LIMIT
2181
+ COVER_STATE['thumb_budget'] = THUMB_FETCH_LIMIT
2182
+
2183
+ # Contact names make a group transcript readable; the ids alone do not.
2184
+ CONTACT_NAMES.update(load_contact_names(args.contact_db, args.contact_key, args.contact_salt))
2185
+ if CONTACT_NAMES:
2186
+ print(f"Contacts: {len(CONTACT_NAMES)} name(s)")
2187
+
2188
+ # Voice: load the payloads so a cached transcript can be shown. Anything
2189
+ # not yet transcribed simply renders as `[语音 N″]`.
2190
+ if _WECHAT_VOICE:
2191
+ VOICE_STATE['cache'] = wechat_voice.TranscriptCache(os.path.join(args.out, '.voice-cache'))
2192
+ try:
2193
+ VOICE_STATE['map'] = wechat_voice.load_voice_map(
2194
+ args.db, args.key, args.salt, args.passphrase, args.talker)
2195
+ except Exception:
2196
+ VOICE_STATE['map'] = {}
2197
+ if VOICE_STATE['map']:
2198
+ done = sum(
2199
+ 1 for blob in VOICE_STATE['map'].values()
2200
+ if VOICE_STATE['cache'].get(wechat_voice.voice_key(blob)) is not None
2201
+ )
2202
+ print(f"Voice: {len(VOICE_STATE['map'])} clip(s), {done} transcribed")
2203
+
2204
+ # The caller resolves the display name from the session list, which only
2205
+ # covers recent conversations. A less recent chat therefore exported as
2206
+ # `wxid_..._part1.html` even though its remark was known all along. Fall
2207
+ # back to the contact database so files are named after the person.
2208
+ if not args.name and args.talker:
2209
+ args.name = CONTACT_NAMES.get(args.talker, '')
2210
+ if args.name:
2211
+ print(f"Name: {args.name} (from contacts)")
673
2212
 
674
2213
  # Connect
675
2214
  print(f"Connecting to {args.db}...")
2215
+ _phase("cover init")
676
2216
  conn, c = connect(args.db, args.key, args.salt)
677
2217
 
678
2218
  # Fetch messages
679
2219
  print(f"Fetching messages for {args.talker}...")
680
- messages = fetch_messages(conn, args.talker)
2220
+ _phase("before fetch")
2221
+ messages = fetch_messages_from_shards(args.db, args.key, args.salt, args.talker, args.date, args.passphrase)
681
2222
 
682
2223
  if not messages:
2224
+ if args.date:
2225
+ file_prefix = sanitize_filename(args.name or args.talker)
2226
+ filepath = os.path.join(args.out, f'{file_prefix}.html')
2227
+ with open(filepath, 'w', encoding='utf-8') as f:
2228
+ f.write(build_empty_html(args.talker, args.date, args.name or args.talker))
2229
+ print(f"No messages found for {args.date}; wrote empty date-scoped page")
2230
+ print(json.dumps({"success": True, "total": 0, "parts": 1, "files": [filepath]}))
2231
+ conn.close()
2232
+ return
683
2233
  print("No messages found!")
684
2234
  conn.close()
685
2235
  sys.exit(1)
686
2236
 
687
- # Build sender name map
2237
+ resource_map = load_resource_media_map(
2238
+ args.account_dir, args.key, args.salt, messages, image_map, args.passphrase
2239
+ )
2240
+ if resource_map:
2241
+ print(f" Found resource mappings for {len(resource_map)} message keys")
2242
+ _phase("resource map")
2243
+
2244
+ # Build sender name map from every message shard because each shard can
2245
+ # contain a different Name2Id mapping.
688
2246
  print(f"Building sender name map...")
689
- sender_map = build_sender_map(conn, args.talker)
2247
+ sender_map = build_sender_map_from_shards(
2248
+ args.db, args.key, args.salt, args.talker, args.passphrase
2249
+ )
690
2250
  print(f" Found {len(sender_map)} sender(s): {list(sender_map.values())}")
691
2251
 
692
2252
  # Format messages
693
2253
  display_name = args.name or args.talker
694
- print(f"Formatting {len(messages)} messages...")
2254
+ _phase("before formatting")
695
2255
  wx_dir = args.wx_dir or ''
2256
+
2257
+ # Remote media is the dominant cost and is discovered one message at a
2258
+ # time, so doing it inline serialises hundreds of round-trips. Sweep the
2259
+ # conversation once to record what it needs (local-only, ~0.1s), fetch it
2260
+ # all concurrently, then format against a warm cache.
2261
+ sink = []
2262
+ PREFETCH['sink'] = sink
2263
+ try:
2264
+ for row in messages:
2265
+ format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid)
2266
+ finally:
2267
+ PREFETCH['sink'] = None
2268
+ if sink:
2269
+ print(f"Prefetching {len(sink)} remote media reference(s)...", flush=True)
2270
+ _phase("prefetch scan")
2271
+ ok, dropped = prefetch_remote(sink)
2272
+ print(f" Resolved {ok} remote item(s)" + (f", skipped {dropped} (cap)" if dropped else ""), flush=True)
2273
+ _phase("prefetch fetch")
2274
+ # Budgets were not charged during the prefetch, so anything it missed
2275
+ # is still bounded by the full per-run allowance here.
2276
+
2277
+ print(f"Formatting {len(messages)} messages...")
696
2278
  formatted = []
697
2279
  img_hit_count = 0
698
2280
  article_img_count = 0
2281
+ progress_step = max(1, len(messages) // 20)
699
2282
  for i, row in enumerate(messages):
700
- if i % 2000 == 0:
701
- print(f" Formatting {i}/{len(messages)}...")
702
- result = format_message(row, args.talker, wx_dir, image_map, sender_map, display_name)
2283
+ if i % progress_step == 0:
2284
+ print(f" Formatting {i}/{len(messages)}...", flush=True)
2285
+ result = format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid)
703
2286
  if result.get('image_b64'):
704
2287
  img_hit_count += 1
705
2288
  if result.get('local_type') == 49:
@@ -709,12 +2292,15 @@ def main():
709
2292
 
710
2293
  # Split into parts (or single file)
711
2294
  total = len(formatted)
712
- if args.single:
2295
+ if args.per_page and args.per_page > 0:
2296
+ parts = max(1, (total + args.per_page - 1) // args.per_page)
2297
+ elif args.single:
713
2298
  parts = 1
714
2299
  else:
715
2300
  parts = min(args.parts, total)
716
2301
  per_part = (total + parts - 1) // parts
717
2302
 
2303
+ _phase(f"formatting {len(messages)} msgs")
718
2304
  print(f"Splitting into {parts} part(s) (~{per_part} messages each)...")
719
2305
 
720
2306
  # Use display name for filename if provided, otherwise fallback to wxid
@@ -729,8 +2315,8 @@ def main():
729
2315
  if not chunk:
730
2316
  break
731
2317
 
732
- html = build_html_page(args.talker, chunk, i + 1, parts, display_name)
733
- if args.single:
2318
+ html = build_html_page(args.talker, chunk, i + 1, parts, display_name, file_prefix)
2319
+ if parts == 1:
734
2320
  filename = f"{file_prefix}.html"
735
2321
  else:
736
2322
  filename = f"{file_prefix}_part{i+1}.html"
@@ -757,6 +2343,17 @@ def main():
757
2343
  }))
758
2344
 
759
2345
 
2346
+ _PHASE_T0 = time.time()
2347
+
2348
+
2349
+ def _phase(label):
2350
+ global _PHASE_T0
2351
+ now = time.time()
2352
+ if os.environ.get("WEFLOW_DEBUG"):
2353
+ print(f" [t+{now - _PHASE_T0:6.1f}s] {label}", flush=True)
2354
+ _PHASE_T0 = now
2355
+
2356
+
760
2357
  def sanitize_filename(name: str) -> str:
761
2358
  """Remove characters unsafe for filenames."""
762
2359
  return re.sub(r'[\\/:*?"<>|]', '_', name)[:80]