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
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a WeChat line-inspection dump into a table plus named photos.
3
+
4
+ An inspector posts photos to a chat and captions them, e.g.
5
+
6
+ [图片][图片]
7
+ 清播线三津I支9-10号杆树障
8
+
9
+ so each caption is one finding: a location (`清播线三津I支9-10号杆`) and a
10
+ problem (`树障`). Photos precede their caption, and one caption can carry
11
+ several problems separated by `;`, in which case every problem gets its own
12
+ row and they share the location.
13
+
14
+ Photos are extracted at their **original** resolution. The HTML exporter caps
15
+ embedded media at 8MB because it inlines them into a page; inspection evidence
16
+ needs the real file, and phone originals run to 8000x6000 / 19MB, so the cap
17
+ must not apply here.
18
+ """
19
+ import csv
20
+ import hashlib
21
+ import io
22
+ import os
23
+ import re
24
+ import sys
25
+ import struct
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+
29
+ import export_chat_html as E
30
+
31
+ # Original files run well past the HTML exporter's inline limit.
32
+ PHOTO_MAX_BYTES = 64 * 1024 * 1024
33
+
34
+ # 线路名以「线」结尾;支线以「支」结尾;杆号形如 9-10号杆 / 23号 / #12
35
+ RE_LINE = re.compile(r'([一-龥]{2,6}线)')
36
+ RE_BRANCH = re.compile(r'([一-龥A-Za-z0-9]{2,20}?支)')
37
+ RE_POLE = re.compile(r'(\d+(?:\s*[-—~]\s*\d+)?\s*号杆|\d+\s*号(?=[^\d]|$)|#\d+(?:\s*[-—~]\s*#?\d+)?)')
38
+ # Location splitting needs the explicit form. The loose `12号` above also
39
+ # matches prose like `万科悦江南11、12号配变在1栋1单元负一楼`, which turned a
40
+ # descriptive paragraph into rows claiming pole numbers that do not exist.
41
+ RE_POLE_STRICT = re.compile(r'(\d+(?:\s*[-—~]\s*\d+)?\s*号杆|#\d+(?:\s*[-—~]\s*#?\d+)?)')
42
+ # Captions that narrate completed work or a whole-site audit rather than one
43
+ # finding at one location; they do not fit the location+problem shape.
44
+ RE_NARRATIVE = re.compile(r'(接收资产巡视|巡视情况|完成对|处理前后)')
45
+ RE_STATUS = re.compile(r'[((]([^))]{0,30}?(?:已处理|未处理|处理中|待处理)[^))]{0,30}?)[))]')
46
+ # A clause that is just a location, e.g. the bare `三津I支9号杆` seen before a caption.
47
+ RE_NUMBERED = re.compile(r'^\s*\d+\s*[、..]\s*')
48
+
49
+
50
+ def _split_clauses(text):
51
+ """One caption's problem clauses, with the numbered-list form unwrapped."""
52
+ parts = re.split(r'[;;]', text)
53
+ out = []
54
+ for part in parts:
55
+ stripped = part.strip()
56
+ if not stripped:
57
+ continue
58
+ # `1、…` / `2、…` list markers carry no meaning of their own.
59
+ out.append(RE_NUMBERED.sub('', stripped))
60
+ return out
61
+
62
+
63
+ def _location_prefix(clause):
64
+ """(location, remainder) - the location tokens at the head of a clause."""
65
+ end = 0
66
+ for pattern in (RE_BRANCH, RE_POLE_STRICT):
67
+ for match in pattern.finditer(clause):
68
+ # Only tokens before the first problem word count as location.
69
+ if match.start() > 24:
70
+ continue
71
+ end = max(end, match.end())
72
+ return clause[:end].strip(',,、 '), clause[end:].strip(',,、 ')
73
+
74
+
75
+ def parse_records(messages, decode=E.decode_message_content):
76
+ """[{time, local_type, text, photos:[local_id]}] for one conversation.
77
+
78
+ Photos accumulate and are attached to the caption that follows them, which
79
+ is how the inspector posts: the picture first, then what it shows.
80
+ """
81
+ records = []
82
+ pending = []
83
+ for row in messages:
84
+ local_id = row[0] or 0
85
+ local_type = (row[2] or 0) & 0xffffffff
86
+ create_time = row[5] or 0
87
+ if local_type == 3:
88
+ pending.append(local_id)
89
+ continue
90
+ if local_type != 1:
91
+ continue
92
+ raw = row[8]
93
+ text = decode(raw) if isinstance(raw, (bytes, bytearray)) else str(raw or '')
94
+ # Group rows prefix the speaker; the record itself starts after it.
95
+ text = re.sub(r'^wxid_[A-Za-z0-9_]+:\s*', '', text).strip()
96
+ if not text or len(text) < 4:
97
+ continue
98
+ if not (RE_POLE.search(text) or RE_BRANCH.search(text) or RE_LINE.search(text)):
99
+ # Chatter between records. The photos stay pending rather than
100
+ # being dropped: a caption usually follows its pictures, so
101
+ # clearing here lost 63 of 101 photos in the sample conversation.
102
+ continue
103
+ records.append({
104
+ 'time': create_time,
105
+ 'text': text,
106
+ 'photos': list(pending),
107
+ 'local_id': local_id,
108
+ })
109
+ pending = []
110
+ return records
111
+
112
+
113
+ def _head_of(clause):
114
+ """`清上线仁义支` - the line-and-branch a clause opens with, else ''.
115
+
116
+ Strict about position: a branch mentioned mid-sentence (`9号杆的仁义支09
117
+ 刀闸`) is not the shared head, and treating it as one sends every later
118
+ clause to the wrong line.
119
+ """
120
+ line = RE_LINE.match(clause)
121
+ if line:
122
+ branch = RE_BRANCH.match(clause, line.end())
123
+ return clause[:branch.end()] if branch else clause[:line.end()]
124
+ branch = RE_BRANCH.match(clause)
125
+ return clause[:branch.end()] if branch else ''
126
+
127
+
128
+ def expand_findings(record):
129
+ """One row per problem clause, carrying the caption's location context.
130
+
131
+ A caption like `清上线仁义支9-11号杆有树障…;9-10号杆…不足5米;9-13号杆杆号
132
+ 牌模糊` names the line once and then lists problems against further poles.
133
+ Later clauses inherit that line-and-branch prefix; without it they arrive
134
+ with an empty location and cannot be attributed to anything.
135
+ """
136
+ rows = []
137
+ head = ''
138
+ # Judged once for the whole caption: a site-by-site audit opens with the
139
+ # marker but only in its first clause, and the rest would otherwise be
140
+ # forced into location+problem rows with invented pole numbers.
141
+ record_narrative = bool(RE_NARRATIVE.search(record['text']))
142
+ for clause in _split_clauses(record['text']):
143
+ narrative = record_narrative
144
+ own = '' if narrative else _head_of(clause)
145
+ if own:
146
+ head = own
147
+ if narrative:
148
+ location, problem = head, clause
149
+ else:
150
+ location, problem = _location_prefix(clause)
151
+ if head and not own:
152
+ location = head + location
153
+ problem = problem or clause[len(location) - len(head):].strip(',,、 ')
154
+ location = location or clause
155
+ if not problem:
156
+ problem = clause[len(location):].strip(',,、 ') or clause
157
+
158
+ line = RE_LINE.match(head or location)
159
+ branch = RE_BRANCH.match(head or location, line.end()) if line else RE_BRANCH.match(head or location)
160
+ pole = None if narrative else (RE_POLE_STRICT.search(clause) or RE_POLE.search(clause))
161
+ status = RE_STATUS.search(clause)
162
+ rows.append({
163
+ 'time': record['time'],
164
+ 'line': line.group(1) if line else '',
165
+ 'branch': branch.group(1) if branch else '',
166
+ 'pole': pole.group(1).replace(' ', '') if pole else '',
167
+ 'location': location,
168
+ 'problem': problem,
169
+ 'status': status.group(1) if status else '',
170
+ 'kind': '叙述' if narrative else ('多问题' if len(_split_clauses(record['text'])) > 1 else '单条'),
171
+ 'photos': record['photos'],
172
+ 'source': record['text'],
173
+ })
174
+ return rows
175
+
176
+
177
+ def decode_photo(path, v2_key):
178
+ """Full-resolution image bytes from a WeChat V2 container, or None.
179
+
180
+ Same container as the exporter decodes, without the size ceiling that
181
+ exists purely to bound inlined HTML.
182
+ """
183
+ from Crypto.Cipher import AES
184
+ from Crypto.Util import Padding
185
+ try:
186
+ with open(path, 'rb') as handle:
187
+ data = handle.read(PHOTO_MAX_BYTES + 1)
188
+ if len(data) > PHOTO_MAX_BYTES or not data.startswith(E.V2_MAGIC):
189
+ return None
190
+ signature, aes_size, xor_size = struct.unpack('<6sLLx', data[:E.V2_CIPHERTEXT_START])
191
+ if signature != E.V2_MAGIC:
192
+ return None
193
+ encrypted_size = aes_size + 16 - aes_size % 16
194
+ encrypted = data[E.V2_CIPHERTEXT_START:E.V2_CIPHERTEXT_START + encrypted_size]
195
+ decrypted = Padding.unpad(AES.new(v2_key[1], AES.MODE_ECB).decrypt(encrypted), 16)
196
+ remainder = data[E.V2_CIPHERTEXT_START + encrypted_size:]
197
+ if xor_size:
198
+ if xor_size > len(remainder):
199
+ return None
200
+ raw = remainder[:-xor_size]
201
+ tail = bytes(value ^ v2_key[0] for value in remainder[-xor_size:])
202
+ else:
203
+ raw, tail = remainder, b''
204
+ output = decrypted + raw + tail
205
+ return output if E.detect_mime_from_bytes(output[:16]) else None
206
+ except Exception:
207
+ return None
208
+
209
+
210
+ def photo_paths(account_dir, talker, resource_md5s):
211
+ """Local candidate files for one message, largest variant first."""
212
+ md5_dir = hashlib.md5(talker.encode()).hexdigest()
213
+ root = os.path.join(account_dir, 'msg', 'attach', md5_dir)
214
+ found = []
215
+ if not os.path.isdir(root):
216
+ return found
217
+ wanted = {}
218
+ for media_md5 in resource_md5s:
219
+ for suffix in ('_h.dat', '.dat'):
220
+ wanted[(media_md5 + suffix).lower()] = len(found)
221
+ for current, _, files in os.walk(root):
222
+ for name in files:
223
+ if name.lower() in wanted:
224
+ found.append((name.lower(), os.path.join(current, name)))
225
+ # `_h.dat` (original) before `.dat` (mid), then by file size.
226
+ found.sort(key=lambda item: (not item[0].endswith('_h.dat'), -os.path.getsize(item[1])))
227
+ return [path for _, path in found]
228
+
229
+
230
+ def sanitize(name):
231
+ """A filename-safe form of a location string."""
232
+ cleaned = re.sub(r'[\\/:*?"<>|\s]+', '_', str(name or '')).strip('_')
233
+ return cleaned[:60] or 'unknown'
234
+
235
+
236
+ def extract_photos(rows, account_dir, talker, resource_map, output_dir, own_wxid='', log=print):
237
+ """Write each row's photos at full resolution, named after its location.
238
+
239
+ Returns (written, missing) where `missing` lists rows whose photos could
240
+ not be decoded - typically because WeChat never downloaded the original,
241
+ which is worth reporting rather than silently substituting a thumbnail.
242
+ """
243
+ os.makedirs(output_dir, exist_ok=True)
244
+ v2_key = E.resolve_v2_media_key(account_dir, own_wxid)
245
+ written = []
246
+ missing = []
247
+ # One caption's photos belong to every row split out of it, so cache by
248
+ # media md5 - otherwise a 3-photo, 4-clause caption writes 12 copies.
249
+ per_media = {}
250
+ for index, row in enumerate(rows, 1):
251
+ produced = []
252
+ for media_md5 in row.get('resource_md5s', []):
253
+ if media_md5 in per_media:
254
+ produced.extend(per_media[media_md5])
255
+ continue
256
+ names = []
257
+ paths = photo_paths(account_dir, talker, [media_md5])
258
+ for slot, path in enumerate(paths, 1):
259
+ data = decode_photo(path, v2_key)
260
+ if not data:
261
+ continue
262
+ mime = E.detect_mime_from_bytes(data[:16])
263
+ if not mime:
264
+ continue
265
+ extension = {'image/jpeg': '.jpg', 'image/png': '.png',
266
+ 'image/gif': '.gif', 'image/webp': '.webp'}.get(mime, '.jpg')
267
+ # The media md5, not a per-photo counter: counters restart at 1
268
+ # for every md5 within a row, so two photos of the same
269
+ # location collided and overwrote each other (101 -> 38 files).
270
+ name = f"{index:03d}_{sanitize(row['location'])}_{media_md5[:8]}{extension}"
271
+ target = os.path.join(output_dir, name)
272
+ if not os.path.exists(target):
273
+ with open(target, 'wb') as handle:
274
+ handle.write(data)
275
+ names.append(name)
276
+ written.append(target)
277
+ if names:
278
+ per_media[media_md5] = names
279
+ produced.extend(names)
280
+ row['photo_files'] = produced
281
+ if not produced and row.get('resource_md5s'):
282
+ missing.append((index, row['location']))
283
+ if index % 20 == 0:
284
+ log(f' 照片 {index}/{len(rows)}')
285
+ return written, missing
286
+
287
+
288
+ def write_table(rows, output_dir, name='巡视记录'):
289
+ """CSV plus an .xlsx when ExcelJS-equivalent tooling is available."""
290
+ columns = [('time', '时间'), ('line', '线路'), ('branch', '支线'), ('pole', '杆号'),
291
+ ('location', '地点'), ('problem', '问题'), ('status', '状态'), ('kind', '类型'),
292
+ ('photo_count', '照片数'), ('photo_files', '照片文件'), ('source', '原始描述')]
293
+ csv_path = os.path.join(output_dir, f'{name}.csv')
294
+ with open(csv_path, 'w', encoding='utf-8-sig', newline='') as handle:
295
+ writer = csv.writer(handle)
296
+ writer.writerow([label for _, label in columns])
297
+ for row in rows:
298
+ import datetime
299
+ record = dict(row)
300
+ record['time'] = datetime.datetime.fromtimestamp(row['time']).strftime('%Y-%m-%d %H:%M:%S')
301
+ record['photo_count'] = len(row.get('photo_files', []))
302
+ record['photo_files'] = '|'.join(row.get('photo_files', []))
303
+ writer.writerow([record.get(key, '') for key, _ in columns])
304
+ return csv_path
305
+
306
+
307
+ def _main():
308
+ import argparse
309
+ import json
310
+ parser = argparse.ArgumentParser(description='把微信里的线路巡视记录整理成表格和照片')
311
+ parser.add_argument('--env', required=True, help='含 db/key/salt/pass/wxid 与账号目录的 JSON')
312
+ parser.add_argument('--talker', required=True)
313
+ parser.add_argument('--account-dir', required=True)
314
+ parser.add_argument('--out', required=True)
315
+ args = parser.parse_args()
316
+
317
+ config = json.load(open(args.env, encoding='utf-8'))
318
+ messages = E.fetch_messages_from_shards(
319
+ config['db'], config['key'], config['salt'], args.talker, '', config['pass'])
320
+ resource_map = E.load_resource_media_map(
321
+ args.account_dir, config['key'], config['salt'], messages, {}, config['pass'])
322
+
323
+ # local_id -> resource md5, so a photo can be found from its message.
324
+ by_local = {}
325
+ for key, values in resource_map.items():
326
+ if key.startswith('server:'):
327
+ continue
328
+ by_local.setdefault(key, []).extend(values)
329
+ for row in messages:
330
+ md5s = list(resource_map.get(f'server:{int(row[1] or 0)}', []))
331
+ if md5s:
332
+ by_local[f'local:{row[0]}'] = md5s
333
+
334
+ records = parse_records(messages)
335
+ rows = []
336
+ for record in records:
337
+ for finding in expand_findings(record):
338
+ finding['resource_md5s'] = []
339
+ for local_id in finding['photos']:
340
+ finding['resource_md5s'].extend(by_local.get(f'local:{local_id}', []))
341
+ rows.append(finding)
342
+
343
+ print(f'解析出 {len(records)} 条记录 -> {len(rows)} 行')
344
+ written, missing = extract_photos(
345
+ rows, args.account_dir, args.talker, by_local,
346
+ os.path.join(args.out, '照片'), own_wxid=config.get('wxid', ''))
347
+ table = write_table(rows, args.out)
348
+ print(json.dumps({'success': True, 'records': len(records), 'rows': len(rows),
349
+ 'photos': len(written), 'missing': len(missing),
350
+ 'table': table}, ensure_ascii=False))
351
+
352
+
353
+ if __name__ == '__main__':
354
+ _main()