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,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render WeChat face codes like [害羞] for HTML export.
|
|
3
|
+
|
|
4
|
+
WeChat stores faces in message text as bracketed names. Two renderers:
|
|
5
|
+
|
|
6
|
+
* Faces we ship the original artwork for (resources/emoji/*.png, MIT, from the
|
|
7
|
+
`wechat-emojis` package) become a <span> whose CSS class carries the image.
|
|
8
|
+
The image is base64-embedded once per page, not once per occurrence.
|
|
9
|
+
* Everything else falls back to the closest standard emoji. WeChat's faces are
|
|
10
|
+
its own artwork, so these are approximations - only used where we have no
|
|
11
|
+
original.
|
|
12
|
+
|
|
13
|
+
Unrecognised codes (including the placeholders this project generates itself,
|
|
14
|
+
such as [图片]) are left untouched.
|
|
15
|
+
"""
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_HERE = Path(__file__).resolve().parent
|
|
23
|
+
_DATA_FILE = _HERE / 'data' / 'wechat_emoji.json'
|
|
24
|
+
EMOJI_DIR = _HERE.parent / 'resources' / 'wechat-emoji'
|
|
25
|
+
|
|
26
|
+
# Keys starting with "_" are documentation, not faces.
|
|
27
|
+
_FACES = {}
|
|
28
|
+
try:
|
|
29
|
+
with open(_DATA_FILE, encoding='utf-8') as fh:
|
|
30
|
+
_FACES = {k: v for k, v in json.load(fh).items() if not k.startswith('_')}
|
|
31
|
+
except (OSError, ValueError):
|
|
32
|
+
_FACES = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_image_faces():
|
|
36
|
+
"""{face name: png path} for every face we have original artwork for."""
|
|
37
|
+
if not EMOJI_DIR.is_dir():
|
|
38
|
+
return {}
|
|
39
|
+
return {p.stem: p for p in EMOJI_DIR.glob('*.png')}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
IMAGE_FACES = _load_image_faces()
|
|
43
|
+
ALL_NAMES = set(_FACES) | set(IMAGE_FACES)
|
|
44
|
+
|
|
45
|
+
# Longest name first so [左哼哼] can never be shadowed by a shorter prefix.
|
|
46
|
+
_ALL_RE = None
|
|
47
|
+
if ALL_NAMES:
|
|
48
|
+
_alternation = '|'.join(re.escape(n) for n in sorted(ALL_NAMES, key=len, reverse=True))
|
|
49
|
+
_ALL_RE = re.compile(r'\[(' + _alternation + r')\]')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def face_class(name):
|
|
53
|
+
"""Stable CSS class for a face with original artwork."""
|
|
54
|
+
return 'wxf-' + hashlib.md5(name.encode()).hexdigest()[:10]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def convert_faces(text):
|
|
58
|
+
"""Replace known [表情] codes with emoji. Unknown codes pass through."""
|
|
59
|
+
if not text or _ALL_RE is None:
|
|
60
|
+
return text
|
|
61
|
+
return _ALL_RE.sub(lambda m: _FACES.get(m.group(1), m.group(0)), text)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_faces(text):
|
|
65
|
+
"""Like convert_faces, but faces with original artwork become a <span>.
|
|
66
|
+
|
|
67
|
+
The returned string is HTML and must NOT be escaped again.
|
|
68
|
+
"""
|
|
69
|
+
if not text or _ALL_RE is None:
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
def repl(m):
|
|
73
|
+
name = m.group(1)
|
|
74
|
+
if name in IMAGE_FACES:
|
|
75
|
+
return f'<span class="wxface {face_class(name)}"></span>'
|
|
76
|
+
return _FACES.get(name, m.group(0))
|
|
77
|
+
|
|
78
|
+
return _ALL_RE.sub(repl, text)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_face(text):
|
|
82
|
+
"""First WeChat face code in `text` as "[名字]", or None.
|
|
83
|
+
|
|
84
|
+
A single regex scan; callers used to loop the whole table doing
|
|
85
|
+
`label in text`, which got expensive once the table grew to ~120.
|
|
86
|
+
"""
|
|
87
|
+
if not text or _ALL_RE is None:
|
|
88
|
+
return None
|
|
89
|
+
m = _ALL_RE.search(text)
|
|
90
|
+
return f"[{m.group(1)}]" if m else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def has_face(text):
|
|
94
|
+
"""True if `text` contains any known face code."""
|
|
95
|
+
if not text or _ALL_RE is None:
|
|
96
|
+
return False
|
|
97
|
+
return _ALL_RE.search(text) is not None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def faces_used_in(html):
|
|
101
|
+
"""CSS classes referenced by an HTML fragment."""
|
|
102
|
+
return set(re.findall(r'wxface (wxf-[0-9a-f]{10})', html))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def face_css(html, indent=' '):
|
|
106
|
+
"""CSS defining just the faces this page actually uses.
|
|
107
|
+
|
|
108
|
+
Embedding only what a page needs keeps paginated exports small - a full
|
|
109
|
+
sprite would add ~1MB to every page.
|
|
110
|
+
"""
|
|
111
|
+
used = faces_used_in(html)
|
|
112
|
+
if not used:
|
|
113
|
+
return ''
|
|
114
|
+
rules = []
|
|
115
|
+
for name, path in sorted(IMAGE_FACES.items()):
|
|
116
|
+
cls = face_class(name)
|
|
117
|
+
if cls not in used:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
b64 = base64.b64encode(path.read_bytes()).decode()
|
|
121
|
+
except OSError:
|
|
122
|
+
continue
|
|
123
|
+
rules.append(f'.{cls} {{ background-image: url(data:image/png;base64,{b64}); }}')
|
|
124
|
+
if not rules:
|
|
125
|
+
return ''
|
|
126
|
+
return indent + ('\n' + indent).join(rules)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == '__main__':
|
|
130
|
+
import sys
|
|
131
|
+
for line in sys.stdin:
|
|
132
|
+
sys.stdout.write(convert_faces(line))
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Decrypt WeChat custom stickers (表情包) from the local cache.
|
|
3
|
+
|
|
4
|
+
WeChat 4.x keeps stickers in `cache/YYYY-MM/Emoticon/<md5[:2]>/<md5>` and
|
|
5
|
+
`business/emoticon/{Persist,Thumb,...}`. Each file is AES-128-CBC with the key
|
|
6
|
+
also used as the IV, where
|
|
7
|
+
|
|
8
|
+
key = md5(f"{seed}{wxid}EMOTICON")[:16]
|
|
9
|
+
|
|
10
|
+
`seed` is a per-account constant held in WeChat's process memory (see
|
|
11
|
+
find_seed). Once known it is stable, so it only has to be scanned for once.
|
|
12
|
+
|
|
13
|
+
Most cached stickers are WeChat's own `wxgf` container - a small header
|
|
14
|
+
followed by a raw H.265 stream - which needs ffmpeg to turn into an image.
|
|
15
|
+
Decoded results are cached on disk because that is the expensive step.
|
|
16
|
+
|
|
17
|
+
Reference: CN-Grace/Wechat-Emoticon-Parser (v4.0-plus branch).
|
|
18
|
+
"""
|
|
19
|
+
import glob
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import tempfile
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from cryptography.hazmat.primitives import padding
|
|
27
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
28
|
+
_HAVE_CRYPTO = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_HAVE_CRYPTO = False
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import zstandard
|
|
34
|
+
_HAVE_ZSTD = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
_HAVE_ZSTD = False
|
|
37
|
+
|
|
38
|
+
IMAGE_MAGICS = (
|
|
39
|
+
(b'\x89PNG', 'image/png', '.png'),
|
|
40
|
+
(b'\xff\xd8\xff', 'image/jpeg', '.jpg'),
|
|
41
|
+
(b'GIF8', 'image/gif', '.gif'),
|
|
42
|
+
(b'RIFF', 'image/webp', '.webp'),
|
|
43
|
+
(b'wxgf', 'image/hevc', '.hevc'),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def derive_key(seed, wxid):
|
|
48
|
+
"""Cache key for an account. Returns 16 bytes."""
|
|
49
|
+
return hashlib.md5(f'{seed}{wxid}EMOTICON'.encode()).digest()[:16]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def account_wxid(account_dir_name):
|
|
53
|
+
"""`wxid_mgnjdl8034eh22_4c8e` -> `wxid_mgnjdl8034eh22`."""
|
|
54
|
+
parts = account_dir_name.rsplit('_', 1)
|
|
55
|
+
return parts[0] if len(parts) == 2 and len(parts[1]) == 4 else account_dir_name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decrypt(raw, key):
|
|
59
|
+
"""AES-128-CBC(key=IV) with PKCS7. Returns plaintext (best effort)."""
|
|
60
|
+
if not _HAVE_CRYPTO or not raw or len(raw) < 32:
|
|
61
|
+
return b''
|
|
62
|
+
body = raw[:len(raw) // 16 * 16]
|
|
63
|
+
dec = Cipher(algorithms.AES(key), modes.CBC(key)).decryptor()
|
|
64
|
+
out = dec.update(body) + dec.finalize()
|
|
65
|
+
try:
|
|
66
|
+
unp = padding.PKCS7(128).unpadder()
|
|
67
|
+
return unp.update(out) + unp.finalize()
|
|
68
|
+
except Exception:
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sniff(data):
|
|
73
|
+
"""(mime, extension) for decrypted bytes, or (None, None)."""
|
|
74
|
+
for magic, mime, ext in IMAGE_MAGICS:
|
|
75
|
+
if data.startswith(magic):
|
|
76
|
+
return mime, ext
|
|
77
|
+
return None, None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_seed_candidates():
|
|
81
|
+
"""Decimal strings scraped from WeChat's process memory (seed candidates)."""
|
|
82
|
+
try:
|
|
83
|
+
import ctypes
|
|
84
|
+
from ctypes import (byref, c_size_t, c_void_p, create_string_buffer,
|
|
85
|
+
sizeof, wintypes)
|
|
86
|
+
import pymem
|
|
87
|
+
import re as _re
|
|
88
|
+
except ImportError:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
class MBI(ctypes.Structure):
|
|
92
|
+
_fields_ = [
|
|
93
|
+
('BaseAddress', ctypes.c_void_p), ('AllocationBase', ctypes.c_void_p),
|
|
94
|
+
('AllocationProtect', wintypes.DWORD), ('PartitionId', wintypes.WORD),
|
|
95
|
+
('RegionSize', ctypes.c_size_t), ('State', wintypes.DWORD),
|
|
96
|
+
('Protect', wintypes.DWORD), ('Type', wintypes.DWORD),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
k32 = ctypes.windll.kernel32
|
|
100
|
+
rpm = k32.ReadProcessMemory
|
|
101
|
+
rpm.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID,
|
|
102
|
+
ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
|
|
103
|
+
vqe = k32.VirtualQueryEx
|
|
104
|
+
vqe.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
|
105
|
+
vqe.restype = ctypes.c_size_t
|
|
106
|
+
|
|
107
|
+
pid = None
|
|
108
|
+
for p in pymem.process.list_processes():
|
|
109
|
+
try:
|
|
110
|
+
name = p.szExeFile
|
|
111
|
+
if isinstance(name, bytes):
|
|
112
|
+
name = name.decode('utf-8', 'ignore')
|
|
113
|
+
if name.lower() == 'weixin.exe':
|
|
114
|
+
pid = p.th32ProcessID
|
|
115
|
+
break
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
if not pid:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
h = k32.OpenProcess(0x0010 | 0x0400, False, pid)
|
|
122
|
+
if not h:
|
|
123
|
+
return []
|
|
124
|
+
|
|
125
|
+
pat = _re.compile(rb'\d{5,20}')
|
|
126
|
+
seen = set()
|
|
127
|
+
addr = 0x10000
|
|
128
|
+
try:
|
|
129
|
+
while addr < 0x7FFFFFFFFFFF:
|
|
130
|
+
mbi = MBI()
|
|
131
|
+
if vqe(h, c_void_p(addr), byref(mbi), sizeof(mbi)) == 0:
|
|
132
|
+
break
|
|
133
|
+
ba, rs = mbi.BaseAddress or 0, mbi.RegionSize or 0
|
|
134
|
+
if rs == 0:
|
|
135
|
+
break
|
|
136
|
+
if (mbi.State == 0x1000 and 256 < rs < 200 * 1024 * 1024
|
|
137
|
+
and mbi.Protect not in (0, 0x01, 0x100)):
|
|
138
|
+
pos, end = ba, ba + rs
|
|
139
|
+
while pos < end:
|
|
140
|
+
n = min(65536, end - pos)
|
|
141
|
+
buf = create_string_buffer(n)
|
|
142
|
+
br = c_size_t(0)
|
|
143
|
+
if rpm(h, c_void_p(pos), buf, n, byref(br)) and br.value:
|
|
144
|
+
for m in pat.finditer(buf.raw[:br.value]):
|
|
145
|
+
s = m.group(0).decode()
|
|
146
|
+
if s not in seen:
|
|
147
|
+
seen.add(s)
|
|
148
|
+
yield s
|
|
149
|
+
pos += 65536
|
|
150
|
+
addr = ba + rs
|
|
151
|
+
finally:
|
|
152
|
+
k32.CloseHandle(h)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def find_seed(wxid, sample_file, candidates=None):
|
|
156
|
+
"""The seed that decrypts `sample_file`, or None.
|
|
157
|
+
|
|
158
|
+
Proven per candidate by decrypting the sample and looking for a known
|
|
159
|
+
image magic - so a false positive is essentially impossible.
|
|
160
|
+
"""
|
|
161
|
+
raw = open(sample_file, 'rb').read(64) if sample_file else b''
|
|
162
|
+
if not raw:
|
|
163
|
+
return None
|
|
164
|
+
for seed in (candidates or find_seed_candidates()):
|
|
165
|
+
if sniff(decrypt(raw, derive_key(seed, wxid)))[0]:
|
|
166
|
+
return seed
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _ffmpeg_exe():
|
|
171
|
+
try:
|
|
172
|
+
import imageio_ffmpeg
|
|
173
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
174
|
+
except Exception:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_FFMPEG = None
|
|
179
|
+
_FFMPEG_CHECKED = False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
WXGF_MAX_FRAMES = 4
|
|
183
|
+
|
|
184
|
+
# Below this share of non-dominant pixels a frame is treated as blank rather
|
|
185
|
+
# than as artwork. Line drawings on white still score well above it; the blank
|
|
186
|
+
# transition frames and truncated payloads sit at essentially 0.
|
|
187
|
+
BLANK_FRAME_SCORE = 0.005
|
|
188
|
+
|
|
189
|
+
# Animation output. The page shows a sticker at ~240px, and uncapped frame
|
|
190
|
+
# rates on a 50-frame clip produce a multi-megabyte GIF.
|
|
191
|
+
GIF_MAX_SIDE = 240
|
|
192
|
+
GIF_FPS = 10
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _frame_score(path):
|
|
196
|
+
"""How much artwork a decoded frame carries, in [0, 1].
|
|
197
|
+
|
|
198
|
+
The share of pixels that are not the frame's dominant colour. Sticker
|
|
199
|
+
streams routinely open with a blank transition frame, which scores ~0.01
|
|
200
|
+
while the drawn frame scores several times that.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
from PIL import Image
|
|
204
|
+
from collections import Counter
|
|
205
|
+
except ImportError:
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
with Image.open(path) as image:
|
|
209
|
+
pixels = list(image.convert('RGB').getdata())
|
|
210
|
+
if not pixels:
|
|
211
|
+
return 0.0
|
|
212
|
+
dominant = Counter(pixels).most_common(1)[0][1]
|
|
213
|
+
return 1.0 - dominant / len(pixels)
|
|
214
|
+
except Exception:
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _encode_animated_gif(source, target):
|
|
219
|
+
"""Animated GIF from an H.265 stream, scaled for a chat bubble.
|
|
220
|
+
|
|
221
|
+
The stream is a sticker animation, not a still. Frames are capped at
|
|
222
|
+
`GIF_FPS` and `GIF_MAX_SIDE`, and quantised through a generated palette -
|
|
223
|
+
without that a 50-frame clip runs to megabytes instead of a few hundred KB.
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
subprocess.run(
|
|
227
|
+
[_FFMPEG, '-y', '-loglevel', 'error', '-f', 'hevc', '-i', source,
|
|
228
|
+
'-vf', (f'fps={GIF_FPS},scale={GIF_MAX_SIDE}:-1:flags=lanczos,'
|
|
229
|
+
'split[a][b];[a]palettegen[p];[b][p]paletteuse'),
|
|
230
|
+
'-loop', '0', target],
|
|
231
|
+
capture_output=True, timeout=120,
|
|
232
|
+
)
|
|
233
|
+
return os.path.isfile(target) and os.path.getsize(target) > 0
|
|
234
|
+
except Exception:
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def decode_wxgf(data):
|
|
239
|
+
"""A `wxgf` payload as image bytes (animated GIF or PNG), or b''.
|
|
240
|
+
|
|
241
|
+
The container is 'wxgf' + a small header, then a raw H.265 stream. Multi
|
|
242
|
+
frame streams are sticker animations and are re-encoded as an animated GIF;
|
|
243
|
+
taking a single frame from those - which is what this used to do - shows a
|
|
244
|
+
still of what the sender saw moving.
|
|
245
|
+
|
|
246
|
+
Single frame streams are handled by picking the frame with the most
|
|
247
|
+
artwork rather than frame 1: many begin with a blank transition frame, so
|
|
248
|
+
frame 1 yields a white square.
|
|
249
|
+
"""
|
|
250
|
+
global _FFMPEG, _FFMPEG_CHECKED
|
|
251
|
+
if not _FFMPEG_CHECKED:
|
|
252
|
+
_FFMPEG = _ffmpeg_exe()
|
|
253
|
+
_FFMPEG_CHECKED = True
|
|
254
|
+
if not _FFMPEG:
|
|
255
|
+
return b''
|
|
256
|
+
tmp_in = None
|
|
257
|
+
tmp_dir = None
|
|
258
|
+
try:
|
|
259
|
+
with tempfile.NamedTemporaryFile(suffix='.hevc', delete=False) as fh:
|
|
260
|
+
fh.write(data[4:])
|
|
261
|
+
tmp_in = fh.name
|
|
262
|
+
tmp_dir = tempfile.mkdtemp(prefix='weflow-wxgf-')
|
|
263
|
+
# Probe the frame count cheaply: all frames, no scaling.
|
|
264
|
+
subprocess.run(
|
|
265
|
+
[_FFMPEG, '-y', '-loglevel', 'error', '-f', 'hevc', '-i', tmp_in,
|
|
266
|
+
'-vsync', '0', os.path.join(tmp_dir, 'f%04d.png')],
|
|
267
|
+
capture_output=True, timeout=120,
|
|
268
|
+
)
|
|
269
|
+
frames = sorted(glob.glob(os.path.join(tmp_dir, 'f*.png')))
|
|
270
|
+
if not frames:
|
|
271
|
+
return b''
|
|
272
|
+
if len(frames) > 1:
|
|
273
|
+
gif_path = os.path.join(tmp_dir, 'anim.gif')
|
|
274
|
+
if _encode_animated_gif(tmp_in, gif_path):
|
|
275
|
+
with open(gif_path, 'rb') as fh:
|
|
276
|
+
return fh.read()
|
|
277
|
+
# Fall through to a still frame if the animation could not be built.
|
|
278
|
+
best = frames[0]
|
|
279
|
+
best_score = None
|
|
280
|
+
for frame in frames[:WXGF_MAX_FRAMES]:
|
|
281
|
+
score = _frame_score(frame)
|
|
282
|
+
if score is None:
|
|
283
|
+
break
|
|
284
|
+
if best_score is None or score > best_score:
|
|
285
|
+
best, best_score = frame, score
|
|
286
|
+
# Every frame blank means the cached payload is not decodable (a
|
|
287
|
+
# truncated download, typically). Report failure so the caller tries
|
|
288
|
+
# another source rather than embedding a white square.
|
|
289
|
+
if best_score is not None and best_score < BLANK_FRAME_SCORE:
|
|
290
|
+
return b''
|
|
291
|
+
with open(best, 'rb') as fh:
|
|
292
|
+
return fh.read()
|
|
293
|
+
except Exception:
|
|
294
|
+
return b''
|
|
295
|
+
finally:
|
|
296
|
+
if tmp_in:
|
|
297
|
+
try:
|
|
298
|
+
os.remove(tmp_in)
|
|
299
|
+
except OSError:
|
|
300
|
+
pass
|
|
301
|
+
if tmp_dir:
|
|
302
|
+
import shutil
|
|
303
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load_sticker(cache_dirs, md5_hex, key, decode_cache_dir=''):
|
|
307
|
+
"""(image bytes, mime) for a sticker by md5, or (b'', '').
|
|
308
|
+
|
|
309
|
+
Decoding wxgf needs ffmpeg and is slow, so results are cached under
|
|
310
|
+
`decode_cache_dir` keyed by md5.
|
|
311
|
+
"""
|
|
312
|
+
if not md5_hex or not key:
|
|
313
|
+
return b'', ''
|
|
314
|
+
if decode_cache_dir:
|
|
315
|
+
cached = os.path.join(decode_cache_dir, md5_hex)
|
|
316
|
+
if os.path.isfile(cached):
|
|
317
|
+
try:
|
|
318
|
+
with open(cached, 'rb') as fh:
|
|
319
|
+
data = fh.read()
|
|
320
|
+
mime, _ = sniff(data)
|
|
321
|
+
if mime:
|
|
322
|
+
return data, mime
|
|
323
|
+
except OSError:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
src = ''
|
|
327
|
+
for base in cache_dirs:
|
|
328
|
+
candidate = os.path.join(base, md5_hex[:2], md5_hex)
|
|
329
|
+
if os.path.isfile(candidate):
|
|
330
|
+
src = candidate
|
|
331
|
+
break
|
|
332
|
+
if not src:
|
|
333
|
+
return b'', ''
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
with open(src, 'rb') as fh:
|
|
337
|
+
raw = fh.read()
|
|
338
|
+
except OSError:
|
|
339
|
+
return b'', ''
|
|
340
|
+
data = decrypt(raw, key)
|
|
341
|
+
mime, _ = sniff(data)
|
|
342
|
+
if mime == 'image/hevc':
|
|
343
|
+
# Re-sniff rather than assuming PNG: an animated stream decodes to a
|
|
344
|
+
# GIF, and labelling that `image/png` sent it down the JPEG path
|
|
345
|
+
# downstream, flattening the animation this was meant to preserve.
|
|
346
|
+
data = decode_wxgf(data)
|
|
347
|
+
mime = sniff(data)[0] or ''
|
|
348
|
+
if not mime:
|
|
349
|
+
return b'', ''
|
|
350
|
+
|
|
351
|
+
# Sticker art is often 512-1024px; the reader renders it at ~240px, so
|
|
352
|
+
# embedding it untouched is pure weight.
|
|
353
|
+
try:
|
|
354
|
+
from wechat_image import shrink
|
|
355
|
+
data, mime = shrink(data, mime, max_side=360)
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
if decode_cache_dir and data:
|
|
360
|
+
try:
|
|
361
|
+
os.makedirs(decode_cache_dir, exist_ok=True)
|
|
362
|
+
with open(os.path.join(decode_cache_dir, md5_hex), 'wb') as fh:
|
|
363
|
+
fh.write(data)
|
|
364
|
+
except OSError:
|
|
365
|
+
pass
|
|
366
|
+
return data, mime
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def sticker_cache_dirs(account_root):
|
|
370
|
+
"""Where WeChat stores cached stickers for this account."""
|
|
371
|
+
dirs = []
|
|
372
|
+
cache_root = os.path.join(account_root, 'cache')
|
|
373
|
+
if os.path.isdir(cache_root):
|
|
374
|
+
for month in sorted(os.listdir(cache_root)):
|
|
375
|
+
d = os.path.join(cache_root, month, 'Emoticon')
|
|
376
|
+
if os.path.isdir(d):
|
|
377
|
+
dirs.append(d)
|
|
378
|
+
for sub in ('Persist', 'Thumb', 'Temp'):
|
|
379
|
+
d = os.path.join(account_root, 'business', 'emoticon', sub)
|
|
380
|
+
if os.path.isdir(d):
|
|
381
|
+
dirs.append(d)
|
|
382
|
+
return dirs
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def any_sticker_file(dirs):
|
|
386
|
+
"""Some cached sticker, used as the verification sample."""
|
|
387
|
+
for base in dirs:
|
|
388
|
+
for sub in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
|
389
|
+
path = os.path.join(base, sub)
|
|
390
|
+
if os.path.isdir(path):
|
|
391
|
+
for name in sorted(os.listdir(path)):
|
|
392
|
+
f = os.path.join(path, name)
|
|
393
|
+
if os.path.isfile(f) and os.path.getsize(f) > 32:
|
|
394
|
+
return f
|
|
395
|
+
return ''
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _main():
|
|
399
|
+
import argparse
|
|
400
|
+
ap = argparse.ArgumentParser(description='WeChat sticker cache helper')
|
|
401
|
+
ap.add_argument('--find-seed', metavar='ACCOUNT_ROOT',
|
|
402
|
+
help='Scan WeChat memory for the sticker seed of this account')
|
|
403
|
+
ap.add_argument('--json', action='store_true')
|
|
404
|
+
args = ap.parse_args()
|
|
405
|
+
|
|
406
|
+
if not args.find_seed:
|
|
407
|
+
ap.print_help()
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
account_root = args.find_seed
|
|
411
|
+
wxid = account_wxid(os.path.basename(os.path.normpath(account_root)))
|
|
412
|
+
dirs = sticker_cache_dirs(account_root)
|
|
413
|
+
sample = any_sticker_file(dirs)
|
|
414
|
+
if not sample:
|
|
415
|
+
print('未找到可用的表情缓存文件,请先在微信里查看几个表情包')
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
seed = find_seed(wxid, sample)
|
|
419
|
+
if not seed:
|
|
420
|
+
print('未找到 seed。请确认微信正在运行,且曾在微信中查看过表情包。')
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
import json as _json
|
|
424
|
+
if args.json:
|
|
425
|
+
print(_json.dumps({'success': True, 'seed': seed, 'wxid': wxid, 'sample': sample}))
|
|
426
|
+
else:
|
|
427
|
+
print(f'✓ seed = {seed}')
|
|
428
|
+
print(f' 账号: {wxid}')
|
|
429
|
+
print(f' 样本: {sample}')
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == '__main__':
|
|
433
|
+
_main()
|