weflow-cli 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +105 -0
- package/CHANGELOG.md +121 -0
- package/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -21
- package/OPERATIONS.md +272 -0
- package/README.en.md +281 -0
- package/README.md +334 -149
- package/SECURITY.md +47 -0
- package/bin/weflow-cli.ts +4050 -581
- package/dist/bin/weflow-cli.js +4310 -588
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +178 -236
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +15 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +177 -19
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/keyService.d.ts +8 -0
- package/dist/src/core/keyService.d.ts.map +1 -1
- package/dist/src/core/keyService.js +127 -22
- package/dist/src/core/keyService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +59 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +201 -65
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +18 -1
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +3 -6
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/services/assistantDaemon.d.ts +19 -0
- package/dist/src/services/assistantDaemon.d.ts.map +1 -0
- package/dist/src/services/assistantDaemon.js +117 -0
- package/dist/src/services/assistantDaemon.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +36 -0
- package/dist/src/services/assistantMemory.d.ts.map +1 -0
- package/dist/src/services/assistantMemory.js +182 -0
- package/dist/src/services/assistantMemory.js.map +1 -0
- package/dist/src/services/assistantPrivacy.d.ts +25 -0
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -0
- package/dist/src/services/assistantPrivacy.js +77 -0
- package/dist/src/services/assistantPrivacy.js.map +1 -0
- package/dist/src/services/assistantRouting.d.ts +18 -0
- package/dist/src/services/assistantRouting.d.ts.map +1 -0
- package/dist/src/services/assistantRouting.js +68 -0
- package/dist/src/services/assistantRouting.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +37 -0
- package/dist/src/services/assistantService.d.ts.map +1 -0
- package/dist/src/services/assistantService.js +284 -0
- package/dist/src/services/assistantService.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +24 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -0
- package/dist/src/services/assistantTools.js +582 -0
- package/dist/src/services/assistantTools.js.map +1 -0
- package/dist/src/services/chatService.d.ts +44 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +105 -4
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +55 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +147 -6
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/evidenceService.d.ts +30 -0
- package/dist/src/services/evidenceService.d.ts.map +1 -0
- package/dist/src/services/evidenceService.js +98 -0
- package/dist/src/services/evidenceService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +13 -20
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +113 -47
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/initKeyService.d.ts +19 -0
- package/dist/src/services/initKeyService.d.ts.map +1 -0
- package/dist/src/services/initKeyService.js +80 -0
- package/dist/src/services/initKeyService.js.map +1 -0
- package/dist/src/services/messageContract.d.ts +23 -0
- package/dist/src/services/messageContract.d.ts.map +1 -0
- package/dist/src/services/messageContract.js +49 -0
- package/dist/src/services/messageContract.js.map +1 -0
- package/dist/src/services/messageQuery.d.ts +7 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -0
- package/dist/src/services/messageQuery.js +30 -0
- package/dist/src/services/messageQuery.js.map +1 -0
- package/dist/src/services/wechat-formatter.js +14 -14
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +31 -3
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/services/whitelistService.d.ts +52 -4
- package/dist/src/services/whitelistService.d.ts.map +1 -1
- package/dist/src/services/whitelistService.js +183 -17
- package/dist/src/services/whitelistService.js.map +1 -1
- package/dist/src/types.d.ts +20 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -0
- package/dist/src/utils/dateRange.js +48 -0
- package/dist/src/utils/dateRange.js.map +1 -0
- package/dist/src/utils/mcpSecurity.d.ts +5 -0
- package/dist/src/utils/mcpSecurity.d.ts.map +1 -0
- package/dist/src/utils/mcpSecurity.js +39 -0
- package/dist/src/utils/mcpSecurity.js.map +1 -0
- package/dist/src/utils/packageRoot.d.ts +2 -0
- package/dist/src/utils/packageRoot.d.ts.map +1 -0
- package/dist/src/utils/packageRoot.js +16 -0
- package/dist/src/utils/packageRoot.js.map +1 -0
- package/dist/src/utils/python.d.ts +2 -0
- package/dist/src/utils/python.d.ts.map +1 -0
- package/dist/src/utils/python.js +72 -0
- package/dist/src/utils/python.js.map +1 -0
- package/dist/src/utils/pythonProcessEnv.d.ts +3 -0
- package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -0
- package/dist/src/utils/pythonProcessEnv.js +46 -0
- package/dist/src/utils/pythonProcessEnv.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +4 -1
- package/dist/src/utils/talkerUtils.d.ts.map +1 -1
- package/dist/src/utils/talkerUtils.js +9 -9
- package/dist/src/utils/talkerUtils.js.map +1 -1
- package/dist/src/utils/wechatEmoji.d.ts +5 -0
- package/dist/src/utils/wechatEmoji.d.ts.map +1 -0
- package/dist/src/utils/wechatEmoji.js +35 -0
- package/dist/src/utils/wechatEmoji.js.map +1 -0
- package/docs/AI_INTERFACE.md +189 -0
- package/docs/BRANCHES.md +87 -0
- package/docs/DATA_CONTRACT.md +74 -0
- package/docs/DECISIONS.md +270 -0
- package/docs/DEEPSEEK_V4_FLASH/346/226/275/345/267/245/346/226/207/346/241/243.md +234 -0
- package/docs/EVIDENCE_GUIDE.md +96 -0
- package/docs/MCP.md +95 -0
- package/docs/PARTNERS.md +41 -0
- package/docs/PROJECT_STATE.md +96 -0
- package/docs/RELEASING.md +124 -0
- package/docs/ROADMAP.md +191 -0
- package/docs/SETUP.md +103 -0
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/docs/images/weflow-architecture.png +0 -0
- package/docs/images/weflow-architecture.svg +65 -0
- package/mcp-server/index.ts +169 -269
- package/package.json +81 -72
- package/requirements-3x.txt +4 -0
- package/requirements-voice.txt +15 -0
- package/requirements.txt +9 -0
- package/resources/js/marked.min.js +69 -0
- package/resources/wechat-emoji/666.png +0 -0
- package/resources/wechat-emoji/Awesome.png +0 -0
- package/resources/wechat-emoji/Concerned.png +0 -0
- package/resources/wechat-emoji/Cry.png +0 -0
- package/resources/wechat-emoji/Emm.png +0 -0
- package/resources/wechat-emoji/Facepalm.png +0 -0
- package/resources/wechat-emoji/Grin.png +0 -0
- package/resources/wechat-emoji/OK.png +0 -0
- package/resources/wechat-emoji/Respect.png +0 -0
- package/resources/wechat-emoji/Sick.png +0 -0
- package/resources/wechat-emoji/Sleep.png +0 -0
- package/resources/wechat-emoji/Smile.png +0 -0
- package/resources/wechat-emoji//344/272/262/344/272/262.png +0 -0
- package/resources/wechat-emoji//344/276/277/344/276/277.png +0 -0
- package/resources/wechat-emoji//345/201/267/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/202/262/346/205/242.png +0 -0
- package/resources/wechat-emoji//345/206/215/350/247/201.png +0 -0
- package/resources/wechat-emoji//345/207/213/350/260/242.png +0 -0
- package/resources/wechat-emoji//345/212/240/346/262/271.png +0 -0
- package/resources/wechat-emoji//345/213/276/345/274/225.png +0 -0
- package/resources/wechat-emoji//345/217/221/345/221/206.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/200/222.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/212/226.png +0 -0
- package/resources/wechat-emoji//345/217/257/346/200/234.png +0 -0
- package/resources/wechat-emoji//345/217/263/345/223/274/345/223/274.png +0 -0
- package/resources/wechat-emoji//345/217/271/346/260/224.png +0 -0
- package/resources/wechat-emoji//345/220/203/347/223/234.png +0 -0
- package/resources/wechat-emoji//345/220/210/345/215/201.png +0 -0
- package/resources/wechat-emoji//345/220/220.png +0 -0
- package/resources/wechat-emoji//345/221/262/347/211/231.png +0 -0
- package/resources/wechat-emoji//345/222/222/351/252/202.png +0 -0
- package/resources/wechat-emoji//345/222/226/345/225/241.png +0 -0
- package/resources/wechat-emoji//345/223/207.png +0 -0
- package/resources/wechat-emoji//345/225/244/351/205/222.png +0 -0
- package/resources/wechat-emoji//345/230/230.png +0 -0
- package/resources/wechat-emoji//345/230/264/345/224/207.png +0 -0
- package/resources/wechat-emoji//345/230/277/345/223/210.png +0 -0
- package/resources/wechat-emoji//345/233/247.png +0 -0
- package/resources/wechat-emoji//345/233/260.png +0 -0
- package/resources/wechat-emoji//345/235/217/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/244/247/345/223/255.png +0 -0
- package/resources/wechat-emoji//345/244/251/345/225/212.png +0 -0
- package/resources/wechat-emoji//345/244/252/351/230/263.png +0 -0
- package/resources/wechat-emoji//345/244/261/346/234/233.png +0 -0
- package/resources/wechat-emoji//345/245/270/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/245/275/347/232/204.png +0 -0
- package/resources/wechat-emoji//345/247/224/345/261/210.png +0 -0
- package/resources/wechat-emoji//345/256/263/347/276/236.png +0 -0
- package/resources/wechat-emoji//345/260/264/345/260/254.png +0 -0
- package/resources/wechat-emoji//345/272/206/347/245/235.png +0 -0
- package/resources/wechat-emoji//345/274/261.png +0 -0
- package/resources/wechat-emoji//345/274/272.png +0 -0
- package/resources/wechat-emoji//345/276/227/346/204/217.png +0 -0
- package/resources/wechat-emoji//345/276/256/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/277/203/347/242/216.png +0 -0
- package/resources/wechat-emoji//345/277/253/345/223/255/344/272/206.png +0 -0
- package/resources/wechat-emoji//346/201/220/346/203/247.png +0 -0
- package/resources/wechat-emoji//346/202/240/351/227/262.png +0 -0
- package/resources/wechat-emoji//346/203/212/346/201/220.png +0 -0
- package/resources/wechat-emoji//346/203/212/350/256/266.png +0 -0
- package/resources/wechat-emoji//346/204/211/345/277/253.png +0 -0
- package/resources/wechat-emoji//346/206/250/347/254/221.png +0 -0
- package/resources/wechat-emoji//346/211/223/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/212/223/347/213/202.png +0 -0
- package/resources/wechat-emoji//346/212/240/351/274/273.png +0 -0
- package/resources/wechat-emoji//346/212/261/346/213/263.png +0 -0
- package/resources/wechat-emoji//346/213/245/346/212/261.png +0 -0
- package/resources/wechat-emoji//346/213/263/345/244/264.png +0 -0
- package/resources/wechat-emoji//346/215/202/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/217/241/346/211/213.png +0 -0
- package/resources/wechat-emoji//346/222/207/345/230/264.png +0 -0
- package/resources/wechat-emoji//346/223/246/346/261/227.png +0 -0
- package/resources/wechat-emoji//346/225/262/346/211/223.png +0 -0
- package/resources/wechat-emoji//346/227/240/350/257/255.png +0 -0
- package/resources/wechat-emoji//346/227/272/346/237/264.png +0 -0
- package/resources/wechat-emoji//346/231/225.png +0 -0
- package/resources/wechat-emoji//346/234/210/344/272/256.png +0 -0
- package/resources/wechat-emoji//346/234/272/346/231/272.png +0 -0
- package/resources/wechat-emoji//346/261/227.png +0 -0
- package/resources/wechat-emoji//346/265/201/346/263/252.png +0 -0
- package/resources/wechat-emoji//347/202/270/345/274/271.png +0 -0
- package/resources/wechat-emoji//347/203/237/350/212/261.png +0 -0
- package/resources/wechat-emoji//347/210/206/347/253/271.png +0 -0
- package/resources/wechat-emoji//347/210/261/345/277/203.png +0 -0
- package/resources/wechat-emoji//347/214/252/345/244/264.png +0 -0
- package/resources/wechat-emoji//347/216/253/347/221/260.png +0 -0
- package/resources/wechat-emoji//347/224/237/347/227/205.png +0 -0
- package/resources/wechat-emoji//347/226/221/351/227/256.png +0 -0
- package/resources/wechat-emoji//347/231/274.png +0 -0
- package/resources/wechat-emoji//347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//347/232/261/347/234/211.png +0 -0
- package/resources/wechat-emoji//347/235/241.png +0 -0
- package/resources/wechat-emoji//347/240/264/346/266/225/344/270/272/347/254/221.png +0 -0
- package/resources/wechat-emoji//347/244/274/347/211/251.png +0 -0
- package/resources/wechat-emoji//347/244/276/344/274/232/347/244/276/344/274/232.png +0 -0
- package/resources/wechat-emoji//347/246/217.png +0 -0
- package/resources/wechat-emoji//347/254/221/350/204/270.png +0 -0
- package/resources/wechat-emoji//347/272/242/345/214/205.png +0 -0
- package/resources/wechat-emoji//347/277/273/347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//350/200/266.png +0 -0
- package/resources/wechat-emoji//350/203/234/345/210/251.png +0 -0
- package/resources/wechat-emoji//350/204/270/347/272/242.png +0 -0
- package/resources/wechat-emoji//350/211/262.png +0 -0
- package/resources/wechat-emoji//350/213/246/346/266/251.png +0 -0
- package/resources/wechat-emoji//350/217/234/345/210/200.png +0 -0
- package/resources/wechat-emoji//350/233/213/347/263/225.png +0 -0
- package/resources/wechat-emoji//350/241/260.png +0 -0
- package/resources/wechat-emoji//350/243/202/345/274/200.png +0 -0
- package/resources/wechat-emoji//350/256/251/346/210/221/347/234/213/347/234/213.png +0 -0
- package/resources/wechat-emoji//350/260/203/347/232/256.png +0 -0
- package/resources/wechat-emoji//350/267/263/350/267/263.png +0 -0
- package/resources/wechat-emoji//350/275/254/345/234/210.png +0 -0
- package/resources/wechat-emoji//351/204/231/350/247/206.png +0 -0
- package/resources/wechat-emoji//351/227/255/345/230/264.png +0 -0
- package/resources/wechat-emoji//351/230/264/351/231/251.png +0 -0
- package/resources/wechat-emoji//351/232/276/350/277/207.png +0 -0
- package/resources/wechat-emoji//351/252/267/351/253/205.png +0 -0
- package/resources/wechat-emoji//351/274/223/346/216/214.png +0 -0
- package/scripts/_batch_link_sources.py +73 -73
- package/scripts/_utils.py +29 -0
- package/scripts/annual_report.py +796 -796
- package/scripts/auto_tag.py +128 -128
- package/scripts/biz_daily.py +210 -53
- package/scripts/chat_report.py +10 -0
- package/scripts/chat_stats.py +702 -702
- package/scripts/classify_daily.py +17 -11
- package/scripts/create_reading_notes.py +370 -370
- package/scripts/daily_stats.py +132 -0
- package/scripts/enrich_backlinks.py +163 -163
- package/scripts/export_chat_html.py +1704 -107
- package/scripts/extract_todos.py +445 -404
- package/scripts/fav_server.py +119 -12
- package/scripts/fix_topics.py +111 -111
- package/scripts/generate_ai_report.py +1 -1
- package/scripts/generate_html.py +597 -277
- package/scripts/inspection_records.py +354 -0
- package/scripts/mcp_bridge.py +398 -398
- package/scripts/nt_decrypt.py +702 -48
- package/scripts/pipeline.py +27 -19
- package/scripts/promote_all.py +312 -310
- package/scripts/promote_ideas.py +299 -297
- package/scripts/rag_chat.py +194 -191
- package/scripts/semantic_search.py +435 -431
- package/scripts/sync_fav.py +201 -146
- package/scripts/sync_weread.py +192 -192
- package/scripts/vault_rag.py +140 -139
- package/scripts/vault_search.py +141 -141
- package/scripts/wechat_emoji.py +132 -0
- package/scripts/wechat_emoticon.py +433 -0
- package/scripts/wechat_image.py +258 -0
- package/scripts/wechat_voice.py +327 -0
- package/src/core/dbPathService.ts +163 -18
- package/src/core/keyService.ts +137 -25
- package/src/core/ntCore.ts +215 -62
- package/src/core/sqlcipherCore.ts +17 -1
- package/src/core/wcdbCore.ts +3 -7
- package/src/services/assistantDaemon.ts +111 -0
- package/src/services/assistantMemory.ts +193 -0
- package/src/services/assistantPrivacy.ts +87 -0
- package/src/services/assistantRouting.ts +92 -0
- package/src/services/assistantService.ts +295 -0
- package/src/services/assistantTools.ts +549 -0
- package/src/services/chatService.ts +133 -4
- package/src/services/configService.ts +194 -6
- package/src/services/evidenceService.ts +129 -0
- package/src/services/exportService.ts +113 -48
- package/src/services/initKeyService.ts +83 -0
- package/src/services/messageContract.ts +77 -0
- package/src/services/messageQuery.ts +38 -0
- package/src/services/wechat-formatter.ts +348 -348
- package/src/services/wechatMessageService.ts +25 -3
- package/src/services/wereadService.ts +308 -308
- package/src/services/whitelistService.ts +209 -22
- package/src/types.ts +20 -0
- package/src/utils/dateRange.ts +58 -0
- package/src/utils/mcpSecurity.ts +39 -0
- package/src/utils/packageRoot.ts +14 -0
- package/src/utils/python.ts +73 -0
- package/src/utils/pythonProcessEnv.ts +45 -0
- package/src/utils/talkerUtils.ts +14 -2
- package/dist/src/utils/errors.d.ts +0 -25
- package/dist/src/utils/errors.d.ts.map +0 -1
- package/dist/src/utils/errors.js +0 -43
- package/dist/src/utils/errors.js.map +0 -1
- package/dist/src/utils/pythonRunner.d.ts +0 -27
- package/dist/src/utils/pythonRunner.d.ts.map +0 -1
- package/dist/src/utils/pythonRunner.js +0 -62
- package/dist/src/utils/pythonRunner.js.map +0 -1
- package/src/utils/errors.ts +0 -42
- package/src/utils/pythonRunner.ts +0 -81
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Decrypt WeChat 4.x chat images from the local `.dat` store.
|
|
3
|
+
|
|
4
|
+
Why this exists: WeChat's thumbnail cache (`cache/YYYY-MM/Message/<会话>/Thumb`)
|
|
5
|
+
holds mainly *article covers* and only ~2 months of history, so ordinary chat
|
|
6
|
+
photos from older conversations render as an empty `[图片]`. The originals are
|
|
7
|
+
on disk the whole time, at
|
|
8
|
+
|
|
9
|
+
msg/attach/<md5(会话)>/<YYYY-MM>/Img/<file_id>.dat (原图)
|
|
10
|
+
msg/attach/<md5(会话)>/<YYYY-MM>/Img/<file_id>_t.dat (缩略图)
|
|
11
|
+
|
|
12
|
+
`file_id` is not an md5 of the image and does not appear in the message XML -
|
|
13
|
+
it comes from `message_resource.db`, see `load_file_ids`.
|
|
14
|
+
|
|
15
|
+
Container (V2, the only variant seen on 4.x):
|
|
16
|
+
|
|
17
|
+
[6B 07 08 56 32 08 07][4B aes_size LE][4B xor_size LE][1B pad]
|
|
18
|
+
+ AES-128-ECB(ciphertext) | plaintext | XOR tail
|
|
19
|
+
|
|
20
|
+
Key derivation reuses the account seed:
|
|
21
|
+
|
|
22
|
+
key = md5(f"{seed}{wxid}").hexdigest()[:16] # 16 ASCII bytes
|
|
23
|
+
|
|
24
|
+
Note the asymmetry with stickers, which use the *first 16 bytes* of
|
|
25
|
+
md5(f"{seed}{wxid}EMOTICON") - a different string and a different slicing.
|
|
26
|
+
|
|
27
|
+
Reference: ZedeX/weixin-decrypte-script.
|
|
28
|
+
"""
|
|
29
|
+
import hashlib
|
|
30
|
+
import os
|
|
31
|
+
import struct
|
|
32
|
+
|
|
33
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
34
|
+
|
|
35
|
+
DAT_SIG = b'\x07\x08\x56\x32\x08\x07'
|
|
36
|
+
|
|
37
|
+
MAGICS = (
|
|
38
|
+
(b'\xff\xd8\xff', 'image/jpeg'),
|
|
39
|
+
(b'\x89PNG', 'image/png'),
|
|
40
|
+
(b'GIF8', 'image/gif'),
|
|
41
|
+
(b'RIFF', 'image/webp'),
|
|
42
|
+
(b'wxgf', 'image/hevc'),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def derive_key(seed, wxid):
|
|
47
|
+
"""16 ASCII bytes from md5(f"{seed}{wxid}")."""
|
|
48
|
+
return hashlib.md5(f'{seed}{wxid}'.encode()).hexdigest()[:16].encode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decrypt(raw, key, seed):
|
|
52
|
+
"""Decrypt a V2 `.dat`, or b'' if it is another variant."""
|
|
53
|
+
if len(raw) < 16 or raw[:6] != DAT_SIG:
|
|
54
|
+
return b''
|
|
55
|
+
try:
|
|
56
|
+
aes_size, xor_size = struct.unpack('<II', raw[6:14])
|
|
57
|
+
except struct.error:
|
|
58
|
+
return b''
|
|
59
|
+
if aes_size + xor_size > len(raw) or aes_size % 16:
|
|
60
|
+
return b''
|
|
61
|
+
body = raw[15:15 + aes_size]
|
|
62
|
+
dec = Cipher(algorithms.AES(key), modes.ECB()).decryptor()
|
|
63
|
+
out = dec.update(body) + dec.finalize()
|
|
64
|
+
end = len(raw) - xor_size if xor_size else len(raw)
|
|
65
|
+
out += raw[15 + aes_size:end]
|
|
66
|
+
if xor_size:
|
|
67
|
+
xk = int(seed) & 0xFF
|
|
68
|
+
out += bytes(b ^ xk for b in raw[end:])
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sniff(data):
|
|
73
|
+
for magic, mime in MAGICS:
|
|
74
|
+
if data.startswith(magic):
|
|
75
|
+
return mime
|
|
76
|
+
return ''
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_file_ids(resource_db, master_key, talker):
|
|
80
|
+
"""{(message_local_id, create_time): file_id} for one conversation.
|
|
81
|
+
|
|
82
|
+
`packed_info` is a small protobuf carrying the on-disk file id as a
|
|
83
|
+
32-character hex string; that string is not derivable from the message, so
|
|
84
|
+
the mapping has to come from this table.
|
|
85
|
+
"""
|
|
86
|
+
import re
|
|
87
|
+
try:
|
|
88
|
+
from sqlcipher3 import dbapi2 as sqlcipher
|
|
89
|
+
from nt_keys import derive_db_key
|
|
90
|
+
except ImportError:
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
if not resource_db or not os.path.isfile(resource_db) or not master_key:
|
|
94
|
+
return {}
|
|
95
|
+
try:
|
|
96
|
+
salt = open(resource_db, 'rb').read(16).hex()
|
|
97
|
+
key = derive_db_key(master_key, resource_db)
|
|
98
|
+
conn = sqlcipher.connect(resource_db)
|
|
99
|
+
cur = conn.cursor()
|
|
100
|
+
cur.execute(f'PRAGMA key = "x\'{key}{salt}\'";')
|
|
101
|
+
chat_id = None
|
|
102
|
+
for rowid, user_name in cur.execute('SELECT rowid, user_name FROM ChatName2Id'):
|
|
103
|
+
if user_name == talker:
|
|
104
|
+
chat_id = rowid
|
|
105
|
+
break
|
|
106
|
+
if chat_id is None:
|
|
107
|
+
conn.close()
|
|
108
|
+
return {}
|
|
109
|
+
out = {}
|
|
110
|
+
cur.execute('''SELECT message_local_id, message_create_time, packed_info
|
|
111
|
+
FROM MessageResourceInfo WHERE chat_id = ?''', (chat_id,))
|
|
112
|
+
for lid, ts, packed in cur.fetchall():
|
|
113
|
+
if isinstance(packed, str):
|
|
114
|
+
packed = packed.encode('latin-1', 'ignore')
|
|
115
|
+
m = re.search(rb'[0-9a-f]{32}', packed or b'')
|
|
116
|
+
if m:
|
|
117
|
+
out[(lid, ts)] = m.group(0).decode()
|
|
118
|
+
conn.close()
|
|
119
|
+
return out
|
|
120
|
+
except Exception:
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def shrink(data, mime, max_side=720, quality=82):
|
|
125
|
+
"""Downscale to at most `max_side` on the long edge, as JPEG.
|
|
126
|
+
|
|
127
|
+
The exported page shows images at ~240px, but a 2-3x display still needs
|
|
128
|
+
more than the 120-210px thumbnails WeChat caches - those upscale into the
|
|
129
|
+
blocky mess users notice. Full images run ~370KB on average though, so
|
|
130
|
+
embedding them untouched would balloon the export; ~720px is the point
|
|
131
|
+
where they look clean at any realistic zoom without the weight.
|
|
132
|
+
"""
|
|
133
|
+
# An animated GIF must not go through a JPEG re-encode: PIL decodes the
|
|
134
|
+
# first frame only, so the export showed a still of what the sender saw
|
|
135
|
+
# moving. Re-encoding also tends to grow small GIFs, so they pass through
|
|
136
|
+
# untouched unless they are genuinely oversized.
|
|
137
|
+
if mime == 'image/gif':
|
|
138
|
+
try:
|
|
139
|
+
import io as _io
|
|
140
|
+
from PIL import Image
|
|
141
|
+
with Image.open(_io.BytesIO(data)) as probe:
|
|
142
|
+
if getattr(probe, 'is_animated', False):
|
|
143
|
+
return data, mime
|
|
144
|
+
except Exception:
|
|
145
|
+
return data, mime
|
|
146
|
+
try:
|
|
147
|
+
import io as _io
|
|
148
|
+
from PIL import Image
|
|
149
|
+
except ImportError:
|
|
150
|
+
return data, mime
|
|
151
|
+
try:
|
|
152
|
+
im = Image.open(_io.BytesIO(data))
|
|
153
|
+
# Chat photos are routinely 4000x3000 or larger. draft() lets the JPEG
|
|
154
|
+
# decoder work at a reduced scale directly instead of decoding the full
|
|
155
|
+
# image and then throwing most of it away - measured ~3x faster, which
|
|
156
|
+
# matters because an export shrinks several hundred of these.
|
|
157
|
+
try:
|
|
158
|
+
im.draft('RGB', (max_side, max_side))
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
im.load()
|
|
162
|
+
w, h = im.size
|
|
163
|
+
if max(w, h) > max_side:
|
|
164
|
+
scale = max_side / max(w, h)
|
|
165
|
+
im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.BILINEAR)
|
|
166
|
+
elif mime == 'image/jpeg':
|
|
167
|
+
return data, mime # already small enough and already compact
|
|
168
|
+
# wxgf decodes to PNG, and PNG at ~700px runs several times the size of
|
|
169
|
+
# the same photo as JPEG - so re-encode unless it would grow the file.
|
|
170
|
+
if im.mode in ('RGBA', 'LA', 'P'):
|
|
171
|
+
# Stickers carry transparency; flatten onto white rather than
|
|
172
|
+
# letting the conversion produce a black background.
|
|
173
|
+
im = im.convert('RGBA')
|
|
174
|
+
bg = Image.new('RGB', im.size, (255, 255, 255))
|
|
175
|
+
bg.paste(im, mask=im.split()[-1])
|
|
176
|
+
im = bg
|
|
177
|
+
else:
|
|
178
|
+
im = im.convert('RGB')
|
|
179
|
+
buf = _io.BytesIO()
|
|
180
|
+
im.save(buf, 'JPEG', quality=quality, optimize=True)
|
|
181
|
+
out = buf.getvalue()
|
|
182
|
+
return (out, 'image/jpeg') if 0 < len(out) < len(data) else (data, mime)
|
|
183
|
+
except Exception:
|
|
184
|
+
return data, mime
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_image(account_root, talker, file_id, key, seed, create_time, decode_cache_dir=''):
|
|
188
|
+
"""(image bytes, mime) for a chat image, or (b'', '').
|
|
189
|
+
|
|
190
|
+
Prefers the full image over the thumbnail: WeChat only caches 120-210px
|
|
191
|
+
thumbnails, and those look blocky at the size the reader renders. The
|
|
192
|
+
result is downscaled to keep the export a sane size.
|
|
193
|
+
"""
|
|
194
|
+
if not file_id or not key:
|
|
195
|
+
return b'', ''
|
|
196
|
+
if decode_cache_dir:
|
|
197
|
+
cached = os.path.join(decode_cache_dir, file_id)
|
|
198
|
+
if os.path.isfile(cached):
|
|
199
|
+
try:
|
|
200
|
+
with open(cached, 'rb') as fh:
|
|
201
|
+
data = fh.read()
|
|
202
|
+
mime = sniff(data)
|
|
203
|
+
if mime:
|
|
204
|
+
return data, mime
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
month = ''
|
|
209
|
+
if create_time:
|
|
210
|
+
import datetime
|
|
211
|
+
month = datetime.datetime.fromtimestamp(create_time).strftime('%Y-%m')
|
|
212
|
+
conv_dir = os.path.join(account_root, 'msg', 'attach',
|
|
213
|
+
hashlib.md5(talker.encode()).hexdigest())
|
|
214
|
+
|
|
215
|
+
# The message's own month first, then the rest newest-first: the month a
|
|
216
|
+
# file lives in is derived from its create_time, but older data sometimes
|
|
217
|
+
# sits in a neighbouring bucket.
|
|
218
|
+
search_dirs = []
|
|
219
|
+
if os.path.isdir(conv_dir):
|
|
220
|
+
if month:
|
|
221
|
+
search_dirs.append(os.path.join(conv_dir, month, 'Img'))
|
|
222
|
+
for m in sorted(os.listdir(conv_dir), reverse=True):
|
|
223
|
+
d = os.path.join(conv_dir, m, 'Img')
|
|
224
|
+
if d not in search_dirs:
|
|
225
|
+
search_dirs.append(d)
|
|
226
|
+
|
|
227
|
+
# Full image first - the thumbnail is only 120-210px and looks blocky.
|
|
228
|
+
candidates = []
|
|
229
|
+
for d in search_dirs:
|
|
230
|
+
candidates.append(os.path.join(d, f'{file_id}.dat'))
|
|
231
|
+
candidates.append(os.path.join(d, f'{file_id}_t.dat'))
|
|
232
|
+
|
|
233
|
+
for path in candidates:
|
|
234
|
+
if not os.path.isfile(path):
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
with open(path, 'rb') as fh:
|
|
238
|
+
raw = fh.read()
|
|
239
|
+
except OSError:
|
|
240
|
+
continue
|
|
241
|
+
data = decrypt(raw, key, seed)
|
|
242
|
+
mime = sniff(data)
|
|
243
|
+
if mime == 'image/hevc':
|
|
244
|
+
from wechat_emoticon import decode_wxgf
|
|
245
|
+
data = decode_wxgf(data)
|
|
246
|
+
mime = 'image/png' if data else ''
|
|
247
|
+
if not mime:
|
|
248
|
+
continue
|
|
249
|
+
data, mime = shrink(data, mime)
|
|
250
|
+
if decode_cache_dir:
|
|
251
|
+
try:
|
|
252
|
+
os.makedirs(decode_cache_dir, exist_ok=True)
|
|
253
|
+
with open(os.path.join(decode_cache_dir, file_id), 'wb') as fh:
|
|
254
|
+
fh.write(data)
|
|
255
|
+
except OSError:
|
|
256
|
+
pass
|
|
257
|
+
return data, mime
|
|
258
|
+
return b'', ''
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Decode WeChat voice messages and transcribe them locally.
|
|
3
|
+
|
|
4
|
+
WeChat 4.x keeps voice payloads in `media_*.db` under `VoiceInfo.voice_data`,
|
|
5
|
+
encoded as SILK v3 (a one-byte length prefix, then `#!SILK_V3`). Browsers
|
|
6
|
+
cannot play SILK and ffmpeg has no SILK decoder, so a chat export has nothing
|
|
7
|
+
to show for a voice message; the audio is decoded with `pilk` and transcribed
|
|
8
|
+
with a local `faster-whisper` model instead.
|
|
9
|
+
|
|
10
|
+
Recognition is expensive next to everything else an export does, so it is
|
|
11
|
+
deliberately kept out of the export path: this module fills a
|
|
12
|
+
content-addressed cache, and the exporter only reads it. That makes the pass
|
|
13
|
+
resumable, re-runnable, and free to repeat once it has been paid for once.
|
|
14
|
+
|
|
15
|
+
The default model is a Cantonese fine-tune, chosen because stock Whisper
|
|
16
|
+
answers Cantonese with fluent-looking Mandarin nonsense - worse than nothing,
|
|
17
|
+
since it reads as a real sentence while saying something that was never said.
|
|
18
|
+
See OPERATIONS.md for how to fetch it.
|
|
19
|
+
|
|
20
|
+
Nothing here touches the network: the model is loaded from the local Hugging
|
|
21
|
+
Face cache and no audio or text leaves the machine.
|
|
22
|
+
"""
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
29
|
+
|
|
30
|
+
# Appended to the transcript cache for a clip the recogniser returned nothing
|
|
31
|
+
# for, so a re-run does not pay for it twice.
|
|
32
|
+
EMPTY_MARKER = '\x00empty'
|
|
33
|
+
|
|
34
|
+
DEFAULT_MODEL = 'small'
|
|
35
|
+
|
|
36
|
+
# A Cantonese-tuned model, fetched separately (see the README note in
|
|
37
|
+
# OPERATIONS.md). Worth preferring automatically when present: stock Whisper
|
|
38
|
+
# answers Cantonese speech with fluent-looking Mandarin nonsense - text that
|
|
39
|
+
# reads as a real sentence but says nothing that was said. A Cantonese
|
|
40
|
+
# fine-tune transcribes the same audio into actual Cantonese.
|
|
41
|
+
CANTONESE_MODEL_DIR = os.path.join(
|
|
42
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
43
|
+
'models', 'whisper-small-cantonese', 'cts')
|
|
44
|
+
|
|
45
|
+
# Detected rather than forced. Forcing `yue` on the Cantonese model returned
|
|
46
|
+
# empty output for every clip tried; letting it detect produced Cantonese.
|
|
47
|
+
DEFAULT_LANGUAGE = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def voice_shards(db_path):
|
|
51
|
+
"""`media_*.db` files that sit beside the configured message database."""
|
|
52
|
+
db = os.path.abspath(db_path)
|
|
53
|
+
folder = os.path.dirname(db)
|
|
54
|
+
if not os.path.isdir(folder):
|
|
55
|
+
return []
|
|
56
|
+
return sorted(
|
|
57
|
+
os.path.join(folder, name)
|
|
58
|
+
for name in os.listdir(folder)
|
|
59
|
+
if name.lower().startswith('media_') and name.lower().endswith('.db')
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _open(path, key_hex, salt_hex, passphrase=''):
|
|
64
|
+
"""Connect to a shard, deriving its key the same way message shards do."""
|
|
65
|
+
from export_chat_html import derive_database_key, connect
|
|
66
|
+
shard_key, shard_salt = derive_database_key(path, key_hex, salt_hex, passphrase)
|
|
67
|
+
conn, cursor = connect(path, shard_key, shard_salt)
|
|
68
|
+
return conn, cursor
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_voice_map(db_path, key_hex, salt_hex, passphrase='', talker=''):
|
|
72
|
+
"""{(talker, local_id): voice bytes} for one conversation.
|
|
73
|
+
|
|
74
|
+
Scoped to a talker because a whole account is only ever needed one
|
|
75
|
+
conversation at a time, and the payloads are held in memory.
|
|
76
|
+
"""
|
|
77
|
+
voices = {}
|
|
78
|
+
if not talker:
|
|
79
|
+
return voices
|
|
80
|
+
for shard in voice_shards(db_path):
|
|
81
|
+
conn = None
|
|
82
|
+
try:
|
|
83
|
+
conn, cursor = _open(shard, key_hex, salt_hex, passphrase)
|
|
84
|
+
cursor.execute('SELECT rowid, user_name FROM Name2Id')
|
|
85
|
+
names = {row[0]: row[1] for row in cursor.fetchall()}
|
|
86
|
+
cursor.execute(
|
|
87
|
+
'SELECT chat_name_id, local_id, voice_data FROM VoiceInfo '
|
|
88
|
+
'WHERE voice_data IS NOT NULL'
|
|
89
|
+
)
|
|
90
|
+
for chat_id, local_id, blob in cursor.fetchall():
|
|
91
|
+
if names.get(chat_id) != talker or not blob:
|
|
92
|
+
continue
|
|
93
|
+
voices[local_id] = bytes(blob)
|
|
94
|
+
except Exception:
|
|
95
|
+
continue
|
|
96
|
+
finally:
|
|
97
|
+
if conn is not None:
|
|
98
|
+
conn.close()
|
|
99
|
+
return voices
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def voice_key(blob):
|
|
103
|
+
"""Cache key for a voice payload: its content digest."""
|
|
104
|
+
return hashlib.md5(blob).hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def decode_silk(blob, wav_path):
|
|
108
|
+
"""Write `blob` to `wav_path` as 16kHz mono WAV. True on success.
|
|
109
|
+
|
|
110
|
+
pilk takes paths rather than bytes, and the 16kHz rate is what Whisper
|
|
111
|
+
wants, so no resampling step is needed afterwards.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
import pilk
|
|
115
|
+
except ImportError:
|
|
116
|
+
return False
|
|
117
|
+
silk_path = wav_path + '.silk'
|
|
118
|
+
try:
|
|
119
|
+
with open(silk_path, 'wb') as handle:
|
|
120
|
+
handle.write(blob)
|
|
121
|
+
pilk.silk_to_wav(silk_path, wav_path, rate=16000)
|
|
122
|
+
return os.path.isfile(wav_path) and os.path.getsize(wav_path) > 44
|
|
123
|
+
except Exception:
|
|
124
|
+
return False
|
|
125
|
+
finally:
|
|
126
|
+
try:
|
|
127
|
+
os.remove(silk_path)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def resolve_model(model_name=''):
|
|
133
|
+
"""Model to load: the caller's choice, else a local Cantonese fine-tune,
|
|
134
|
+
else the stock model name."""
|
|
135
|
+
if model_name:
|
|
136
|
+
return model_name
|
|
137
|
+
if os.path.isdir(CANTONESE_MODEL_DIR):
|
|
138
|
+
return CANTONESE_MODEL_DIR
|
|
139
|
+
return DEFAULT_MODEL
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _prepare_cuda_dlls():
|
|
143
|
+
"""Put the pip-provided CUDA libraries on PATH.
|
|
144
|
+
|
|
145
|
+
ctranslate2 resolves cuBLAS/cuDNN through PATH on Windows and ignores
|
|
146
|
+
`os.add_dll_directory`, so the `nvidia-*` wheel directories have to be
|
|
147
|
+
prepended before it is imported. Without this, a `cuda` request fails with
|
|
148
|
+
"Library cublas64_12.dll is not found" even when the wheels are installed.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
import glob
|
|
152
|
+
import sysconfig
|
|
153
|
+
except ImportError:
|
|
154
|
+
return
|
|
155
|
+
if os.name != 'nt':
|
|
156
|
+
return
|
|
157
|
+
site = sysconfig.get_paths().get('purelib', '')
|
|
158
|
+
if not site:
|
|
159
|
+
return
|
|
160
|
+
dirs = glob.glob(os.path.join(site, 'nvidia', '*', 'bin'))
|
|
161
|
+
if dirs:
|
|
162
|
+
os.environ['PATH'] = os.pathsep.join(dirs) + os.pathsep + os.environ.get('PATH', '')
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_model(model_name='', device='auto'):
|
|
166
|
+
"""A Whisper model, or None when faster-whisper is unavailable.
|
|
167
|
+
|
|
168
|
+
`auto` prefers the GPU: on the tested hardware a clip goes from 2.0s to
|
|
169
|
+
0.07s, which is the difference between an overnight pass and a coffee
|
|
170
|
+
break. Falls back to CPU whenever CUDA is not usable, so a machine without
|
|
171
|
+
the GPU libraries still works, just slower.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
from faster_whisper import WhisperModel
|
|
175
|
+
except ImportError:
|
|
176
|
+
return None
|
|
177
|
+
order = ('cuda', 'cpu') if device in ('auto', 'cuda') else ('cpu',)
|
|
178
|
+
for candidate in order:
|
|
179
|
+
if candidate == 'cuda':
|
|
180
|
+
_prepare_cuda_dlls()
|
|
181
|
+
try:
|
|
182
|
+
compute = 'float16' if candidate == 'cuda' else 'int8'
|
|
183
|
+
return WhisperModel(resolve_model(model_name), device=candidate, compute_type=compute)
|
|
184
|
+
except Exception:
|
|
185
|
+
continue
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TranscriptCache:
|
|
190
|
+
"""Content-addressed transcripts on disk.
|
|
191
|
+
|
|
192
|
+
Keyed by the voice payload's digest rather than by message id: the same
|
|
193
|
+
clip forwarded between chats is recognised once, and a re-export after the
|
|
194
|
+
message ids shift still hits.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(self, cache_dir):
|
|
198
|
+
self.dir = cache_dir or ''
|
|
199
|
+
|
|
200
|
+
def path(self, key):
|
|
201
|
+
return os.path.join(self.dir, key) if self.dir else ''
|
|
202
|
+
|
|
203
|
+
def get(self, key):
|
|
204
|
+
"""Transcript, or None when this clip has not been processed yet."""
|
|
205
|
+
path = self.path(key)
|
|
206
|
+
if not path or not os.path.isfile(path):
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
with open(path, 'r', encoding='utf-8') as handle:
|
|
210
|
+
text = handle.read()
|
|
211
|
+
except OSError:
|
|
212
|
+
return None
|
|
213
|
+
return '' if text == EMPTY_MARKER else text
|
|
214
|
+
|
|
215
|
+
def put(self, key, text):
|
|
216
|
+
path = self.path(key)
|
|
217
|
+
if not path:
|
|
218
|
+
return
|
|
219
|
+
try:
|
|
220
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
221
|
+
with open(path, 'w', encoding='utf-8') as handle:
|
|
222
|
+
handle.write(text if text else EMPTY_MARKER)
|
|
223
|
+
except OSError:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def transcribe_wav(wav_path, model, beam_size=1, language=DEFAULT_LANGUAGE):
|
|
228
|
+
"""Transcript for a WAV file, or '' when nothing was recognised."""
|
|
229
|
+
try:
|
|
230
|
+
segments, _ = model.transcribe(wav_path, language=language, beam_size=beam_size)
|
|
231
|
+
return ''.join(segment.text for segment in segments).strip()
|
|
232
|
+
except Exception:
|
|
233
|
+
return ''
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def ensure_transcripts(voices, cache_dir, model_name='',
|
|
237
|
+
beam_size=1, limit=0, language=DEFAULT_LANGUAGE,
|
|
238
|
+
device='auto', log=print):
|
|
239
|
+
"""Fill the transcript cache for any clip that is missing one.
|
|
240
|
+
|
|
241
|
+
Returns (processed, cached, failed). Pending work is measured by cache
|
|
242
|
+
misses, so an interrupted run resumes where it stopped instead of starting
|
|
243
|
+
over - which matters, because a long conversation is hours of audio.
|
|
244
|
+
"""
|
|
245
|
+
cache = TranscriptCache(cache_dir)
|
|
246
|
+
pending = []
|
|
247
|
+
for local_id, blob in voices.items():
|
|
248
|
+
if not blob:
|
|
249
|
+
continue
|
|
250
|
+
key = voice_key(blob)
|
|
251
|
+
if cache.get(key) is None:
|
|
252
|
+
pending.append((key, blob))
|
|
253
|
+
|
|
254
|
+
# Count the cache hits before --limit trims the queue, otherwise a limited
|
|
255
|
+
# run reports everything it did not process as already done.
|
|
256
|
+
already = len(voices) - len(pending)
|
|
257
|
+
if limit and limit > 0:
|
|
258
|
+
pending = pending[:limit]
|
|
259
|
+
if not pending:
|
|
260
|
+
log(f'所有 {len(voices)} 条语音都已有转写(缓存命中)')
|
|
261
|
+
return 0, already, 0
|
|
262
|
+
|
|
263
|
+
resolved = resolve_model(model_name)
|
|
264
|
+
log(f'待转写 {len(pending)} 条(已有 {already} 条)')
|
|
265
|
+
log(f'模型: {resolved}')
|
|
266
|
+
model = load_model(model_name, device=device)
|
|
267
|
+
if model is None:
|
|
268
|
+
log('无法加载 faster-whisper 模型,转写跳过')
|
|
269
|
+
return 0, already, len(pending)
|
|
270
|
+
|
|
271
|
+
import tempfile
|
|
272
|
+
processed = failed = 0
|
|
273
|
+
scratch = tempfile.mkdtemp(prefix='weflow-voice-')
|
|
274
|
+
try:
|
|
275
|
+
for index, (key, blob) in enumerate(pending, 1):
|
|
276
|
+
wav_path = os.path.join(scratch, f'{index}.wav')
|
|
277
|
+
if not decode_silk(blob, wav_path):
|
|
278
|
+
cache.put(key, '')
|
|
279
|
+
failed += 1
|
|
280
|
+
else:
|
|
281
|
+
text = transcribe_wav(wav_path, model, beam_size=beam_size, language=language)
|
|
282
|
+
cache.put(key, text)
|
|
283
|
+
processed += 1
|
|
284
|
+
try:
|
|
285
|
+
os.remove(wav_path)
|
|
286
|
+
except OSError:
|
|
287
|
+
pass
|
|
288
|
+
if index % 10 == 0 or index == len(pending):
|
|
289
|
+
log(f' 转写 {index}/{len(pending)}(新增 {processed},失败 {failed})')
|
|
290
|
+
finally:
|
|
291
|
+
try:
|
|
292
|
+
import shutil
|
|
293
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return processed, already, failed
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _main():
|
|
300
|
+
import argparse
|
|
301
|
+
parser = argparse.ArgumentParser(description='微信语音转写(本地 Whisper)')
|
|
302
|
+
parser.add_argument('--db', required=True, help='message_0.db 路径,用于定位同目录的 media_*.db')
|
|
303
|
+
parser.add_argument('--key', required=True, help='NT key hex')
|
|
304
|
+
parser.add_argument('--salt', required=True, help='NT salt hex')
|
|
305
|
+
parser.add_argument('--passphrase', default='', help='分片密钥口令')
|
|
306
|
+
parser.add_argument('--talker', required=True, help='会话 id')
|
|
307
|
+
parser.add_argument('--cache-dir', required=True, help='转写缓存目录')
|
|
308
|
+
parser.add_argument('--model', default='', help='模型名或本地目录(默认优先用本地粤语模型)')
|
|
309
|
+
parser.add_argument('--language', default='', help='语言代码;留空为自动检测(粤语模型必须留空)')
|
|
310
|
+
parser.add_argument('--device', default='auto', choices=['auto', 'cuda', 'cpu'], help='推理设备')
|
|
311
|
+
parser.add_argument('--beam-size', type=int, default=1)
|
|
312
|
+
parser.add_argument('--limit', type=int, default=0, help='本次最多转写多少条(0 为不限)')
|
|
313
|
+
args = parser.parse_args()
|
|
314
|
+
|
|
315
|
+
voices = load_voice_map(args.db, args.key, args.salt, args.passphrase, args.talker)
|
|
316
|
+
print(f'会话 {args.talker}: 找到 {len(voices)} 条语音', flush=True)
|
|
317
|
+
processed, already, failed = ensure_transcripts(
|
|
318
|
+
voices, args.cache_dir, model_name=args.model,
|
|
319
|
+
beam_size=args.beam_size, limit=args.limit,
|
|
320
|
+
language=args.language or None, device=args.device)
|
|
321
|
+
print(json.dumps({'success': True, 'voices': len(voices),
|
|
322
|
+
'transcribed': processed, 'cached': already, 'failed': failed},
|
|
323
|
+
ensure_ascii=False))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == '__main__':
|
|
327
|
+
_main()
|