进行后续操作前必须获得密钥可参考 实现微信聊天记录导出中密钥的获取 –
题外话:有师傅问是否可以不退出重新登录获得密钥。在 Hook 方案成功之前,还尝试了一种暴力搜索方案:
思路:从微信进程内存中扫描所有 64 位 hex 字符串作为候选密钥,逐个尝试用 wcdb_open_account 打开数据库。
测试结果:在实际测试中共得到917 个候选密钥。测试全部 917 个候选,耗时 512.5 秒,无一命中。
结论:内存中直接扫描密钥的方式不可行,密钥在内存中可能经过加密或分片存储,必须通过 Hook 密钥传递函数才能准确获取。如果其他师傅有新的思路欢迎交流,现在言归正传。
数据存储架构
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
C:\Users<用户名>\Documents\xwechat_files<wxid>\db_storage\├── session/ ← 会话列表│ └── session.db├── message/ ← 聊天消息│ ├── message_0.db 消息主体│ ├── message_0.db-wal WAL 日志(含未提交数据)│ ├── message_0.db-shm 共享内存索引│ ├── message_0.kvdb KV 附件│ ├── biz_message_0.db 公众号消息│ ├── media_0.db 媒体资源│ ├── message_fts.db 全文搜索索引│ └── message_resource.db 消息资源映射├── contact/ ← 联系人│ ├── contact.db 联系人主体│ └── contact_fts.db 联系人搜索索引├── emoticon/ ← 自定义表情├── favorite/ ← 收藏├── head_image/ ← 头像├── sns/ ← 朋友圈├── hardlink/ ← 文件硬链接├── general/ ← 通用配置└── ... |
操作中 可直接读取原始路径WCDB 内部使用 SQLite 的只读模式打开,以共享方式访问文件,不锁定数据库,因此即使微信正在运行也能正常读取。实际测试时先将 .db + .db-wal + .db-shm 三组文件复制到 .temp/dbs/,再从副本读取。
WCDB
官方开源地址:https://github.com/Tencent/wcdb 实际测试中是直接使用了VIWOO导出工具中的包
加载顺序:runtime → WCDB → SDL2 → wcdb_api,缺一不可;
绝不调用 wcdb_free_string(必崩)
数据结构详解
session.db —— 会话列表通过 wcdb_get_sessions(handle) 获取,返回 JSON 数组,每条记录代表一个会话(聊天窗口)。实际返回字段(均为字符串):[td]
|
字段
|
类型
|
说明
|
示例值
|
| username | string | 会话 ID(群聊为 xxx@chatroom,单聊为 wxid_xxx) | 21087793210@chatroom |
| summary | string | 最后一条消息摘要 | 您家的张宏老师的动态 |
| last_timestamp | string | 最后消息时间戳(Unix 秒) | 1788024230 |
| sort_timestamp | string | 排序时间戳 | 1788024230 |
| last_msg_type | string | 最后消息主类型 | 49(app) |
| last_msg_sub_type | string | 最后消息子类型 | 51(channels) |
| last_msg_sender | string | 最后消息发送者 wxid | TMH37499058 |
| last_sender_display_name | string | 最后消息发送者显示名 | 小乐 |
| last_msg_locald_id | string | 最后消息 local_id | 50 |
| unread_count | string | 未读消息数 | 1 |
| unread_first_msg_srv_id | string | 第一条未读消息 server_id | 1107298569773271755 |
| type | string | 会话类型 | 0 |
| status | string | 会话状态 | 0 |
| is_hidden | string | 是否隐藏 | 0 |
| draft | string | 草稿内容 | “” |
contact.db —— 联系人通过 wcdb_get_contacts_compact(handle, “[]”) 获取,传空数组 JSON “[]” 返回全部联系人(本例 12305 个)。返回字段:[td]
|
字段
|
说明
|
示例值
|
| username | 联系人 ID | wxid_q9tvnagk60zg22 |
| nick_name | 昵称 | M0r14rtyKK |
| remark | 备注名(用户自定义) | 张三 |
| alias | 微信号 | zhangsan123 |
| local_type | 联系人类型 | 1(好友)/2(群聊)/4(公众号) |
显示名称优先级:remark > nick_name > alias > username群聊成员昵称通过单独的 API 获取:
|
1
2
|
wcdb_get_group_nicknames(handle, "470178077@chatroom")// 返回:{ "king0196": "老王", "wxid_abc123": "李四", ... } |
message_0.db —— 聊天消息通过 wcdb_get_messages(handle, sessionId, limit, offset) 分页读取,每页 500 条。原始消息字段(实际数据库返回值):[td]
|
字段
|
类型
|
说明
|
示例值
|
| local_id | string | 本地消息 ID(会话内自增) | 50 |
| server_id | string | 服务器消息 ID(18~19 位大整数) | 1107298569773271755 |
| server_seq | string | 服务器序号 | 771629425 |
| sort_seq | string | 排序序号(毫秒级时间戳) | 1788024230000 |
| create_time | string | 消息时间戳(Unix 秒) | 1788024230 |
| local_type | string | 64 位复合类型码 | 219043332145 |
| is_send | string | 是否自己发送 | 0(接收)/1(发送) |
| message_content | string | 消息内容(hex/zstd 或明文) | 见下方解析 |
| compress_content | string | 压缩内容(优先使用) | “”(常为空) |
| source | string | 消息来源(hex/zstd) | 28b52ffd… |
| status | string | 消息状态 | 3 |
| download_status | string | 下载状态 | 0 |
| upload_status | string | 上传状态 | 0 |
| sender_username | string | 发送者 wxid | TMH37499058 |
| real_sender_id | string | 发送者内部 ID | 127 |
| table_name | string | 消息表名(按会话 hash 分表) | Msg_62ba9cd2… |
| packed_info_data | string | 打包信息 | 080610035800 |
| origin_source | string | 原始来源标记 | 2 |
| WCDB_CT_message_content | string | 内容类型标记 | 4(压缩) |
| WCDB_CT_source | string | 来源类型标记 | 4 |
注意事项:server_id 是 18~19 位大整数,超过 JS Number.MAX_SAFE_INTEGER(2^53),必须转为字符串后再 JSON.parse,否则精度丢失。
消息内容解码与解析
消息内容存储格式 微信 4.x 的消息内容存储在 message_content / compress_content 两个字段中,存在三种情况:[td]
|
情况
|
WCDB_CT_message_content
|
内容格式
|
示例
|
| 明文文本 | 0 | UTF-8 字符串,群聊以 wxid:\n 开头 | wxid_abc:\n你好 |
| 压缩文本 | 4 | hex 编码的 zstd 压缩数据 | 28b52ffd608e13bd… |
| 压缩 XML | 4 | hex → zstd → UTF-8 XML | 28b52ffd… → <msg>…</msg> |
消息类型64 位复合类型码 local_type微信 4.x 的 local_type 是 64 位整数,取低 32 位才是主类型,格式为 (subType << 32) | mainType:
|
1
2
3
4
|
219043332145 (十进制)= 0x33_00000031 (十六进制)= subType = 0x33 = 51, mainType = 0x31 = 49→ 主类型 49 = app(应用消息),子类型 51 = channels(视频号) |
提取主类型的方式:mainType = local_type & 0xFFFFFFFF(取低 32 位)
主类型映射表[td]
|
主类型码
|
类型名
|
说明
|
| 1 | text | 文本消息 |
| 3 | image | 图片消息 |
| 34 | voice | 语音消息 |
| 42 | card | 名片 |
| 43 | video | 视频 |
| 47 | emoji | 动画表情 |
| 48 | location | 位置 |
| 49 | app | 应用消息(含大量子类型) |
| 50 | voip | VoIP 通话 |
| 10000 | system | 系统消息 |
| 10002 | revoke | 撤回消息 |
Type49 应用消息子类型(XML <type> 字段)[td]
|
子类型码
|
类型名
|
说明
|
| 4/6 | file | 文件 |
| 5 | link | 链接 |
| 8 | image | 图片(通过应用消息发送) |
| 19 | merged_forward | 合并转发 |
| 33/36 | miniapp | 小程序 |
| 40/76/80 | collect | 收藏 |
| 44 | video | 视频 |
| 50 | voip | 通话 |
| 51 | channels | 视频号 |
| 57 | quote | 引用消息 |
| 87 | group_notice | 群公告 |
| 2000 | transfer | 转账 |
| 2001 | red_packet | 红包 |
| 2003 | video_call | 视频通话 |
解码链路
消息内容是 hex 编码的 zstd 压缩数据,必须 hex → zstd → UTF-8 才能得到可读文本,否则乱码。decodeMessageContent(messageContent, compressContent) 的完整处理流程:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
输入: message_content / compress_content (字符串)│├─ 1. 优先尝试 compress_content(通常为空)│ ├─ hex 解码 → Buffer│ ├─ 检测 zstd 魔数 (28 b5 2f fd 或 42 42 76 22 16)│ │ ├─ 是 zstd → fzstd.decompress → UTF-8 文本 ✓│ │ └─ 非 zstd → looksLikeText 检查│ │ ├─ 可读 → 返回文本 ✓│ │ └─ 乱码 → 返回空字符串(避免二进制污染)│ └─ 直接返回原始文本│├─ 2. 尝试 message_content│ ├─ 明文检测:是否为合法 hex 字符串?│ │ ├─ 是 hex → Buffer.from(hex)│ │ │ ├─ 检测 zstd 魔数│ │ │ │ ├─ 是 zstd → fzstd.decompress → UTF-8 文本 ✓ ← 核心修复点│ │ │ │ └─ 非 zstd → looksLikeText → 返回或丢弃│ │ │ └─ (非 zstd 的 hex 解码结果)│ │ └─ 非 hex → 尝试 base64 → 直接返回原始文本│ └─ (Buffer 模式同理)│└─ 3. 返回空字符串 |
zstd 魔数:[td]
|
魔数 (十六进制)
|
来源
|
说明
|
| 28 b5 2f fd | 标准 zstd | RFC 8478 标准魔术字节,微信 4.x 实际使用 |
looksLikeText 函数:用于防止二进制数据被当作文本输出导致乱码。检测规则:遍历字符串字符,统计 U+FFFD(替换符)和控制字符数,超过 10% 则判定为二进制数据并返回空字符串。按类型解析解码得到原始文本/XML 后,根据主类型走不同分支:[td]
|
类型
|
解析逻辑
|
| text | stripSenderPrefix:群聊消息剥离 wxid:\n 前缀 |
| image | 输出 [图片] 占位符 |
| voice | 输出 [语音消息],可附加 voice_length 时长 |
| video | 输出 [视频] |
| emoji | 输出 [动画表情] |
| card | 输出 [名片] |
| location | 输出 [位置] |
| system | 解码内容若为 XML 则解析,否则直接输出 |
| revoke | 输出 [撤回了一条消息] |
| app (type=49) | 解析 XML (<appmsg> 结构),按 <type> 子类型提取标题/描述/URL等 |
WCDB API 清单
通过 koffi FFI 调用 wcdb_api.dll,使用到的 API 及其签名: 生命周期[td]
|
API
|
签名
|
说明
|
| wcdb_init | int32_t wcdb_init() | 初始化 WCDB 运行时(全局一次) |
| wcdb_shutdown | int32_t wcdb_shutdown() | 关闭 WCDB 运行时 |
| wcdb_open_account | int32_t wcdb_open_account(const char *path, const char *key, _Out_ int64_t *handle) | 打开数据库,返回句柄 |
| wcdb_close_account | int32_t wcdb_close_account(int64_t handle) | 关闭数据库 |
| wcdb_set_my_wxid | int32_t wcdb_set_my_wxid(int64_t handle, const char *wxid) | 设置本人 wxid(解析群消息方向用) |
会话 & 消息[td]
|
API
|
签名
|
说明
|
| wcdb_get_sessions | (int64_t handle, _Out_ const char **outJson) | 获取全部会话列表 |
| wcdb_get_message_count | (int64_t handle, const char *username, _Out_ int32_t *outCount) | 获取某会话消息总数 |
| wcdb_get_messages | (int64_t handle, const char *username, int32_t limit, int32_t offset, _Out_ const char **outJson) | 分页读取消息 |
| wcdb_get_message_by_id | (int64_t handle, const char *sessionId, int32_t localId, _Out_ const char **outJson) | 按 local_id 精确读取单条 |
| wcdb_list_message_dbs | (int64_t handle, _Out_ const char **outJson) | 列出所有消息库路径 |
| wcdb_search_messages | (int64_t handle, const char *keyword, int32_t limit, int32_t offset, _Out_ const char **outJson) | 全文搜索消息 |
联系人 & 群[td]
|
API
|
签名
|
说明
|
| wcdb_get_contacts_compact | (int64_t handle, const char *usernamesJson, _Out_ const char **outJson) | 传 [] 返回全部联系人;传 wxid 数组返回指定联系人 |
| wcdb_get_contact | (int64_t handle, const char *username, _Out_ const char **outJson) | 获取单个联系人详情 |
| wcdb_get_group_nicknames | (int64_t handle, const char *chatroomId, _Out_ const char **outJson) | 获取群成员昵称映射 |
| wcdb_get_group_members | (int64_t handle, const char *chatroomId, _Out_ const char **outJson) | 获取群成员列表(含头像) |
| wcdb_get_avatar_urls | (int64_t handle, const char *usernamesJson, _Out_ const char **outJson) | 批量获取头像 URL |
通用[td]
|
API
|
签名
|
说明
|
| wcdb_exec_query | (int64_t handle, const char *dbType, const char *sql, _Out_ const char **outJson) | 执行自定义 SQL |
| wcdb_get_voice_data | (int64_t handle, const char *sessionId, const char *svrid, _Out_ void **outData, _Out_ int32_t *outSize) | 获取语音原始数据 |
API 调用约定:
- 所有返回 JSON 的 API 使用 _Out_ const char ** 模式,koffi 自动转为 JS 字符串;
- 勿调用 wcdb_free_string(koffi 模式下已自动释放,手动 free 会 double-free 崩溃);
- 返回的 JSON 中 server_id 为大整数,需正则转为字符串后再 JSON.parse;
- 所有字段值为字符串,数值字段需 parseInt。
测试代码:
消息解析:
|
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
|
/** * messageParser.js * 微信消息解析模块 * * 负责解析微信数据库中的原始消息,包括: * - 消息内容解码(hex/base64/zstd 压缩内容) * - 消息类型映射(文本/图片/语音/视频/引用/红包/转账等) * - Type49 应用消息 XML 解析 * - 图片格式验证 */const fzstd = require('fzstd');const md5 = require('md5');// zstd 压缩数据的魔术字节: 0x42 0x42 0x76 0x22 0x16const ZSTD_MAGIC = Buffer.of(0x42, 0x42, 0x76, 0x22, 0x16);// 消息类型码映射(对应 exportWorker.js 中的 typeMap)const MESSAGE_TYPES = { 1: 'text', // 文本消息 3: 'image', // 图片消息 34: 'voice', // 语音消息 42: 'card', // 名片 43: 'video', // 视频 47: 'emoji', // 动画表情 48: 'location', // 位置 49: 'app', // 应用消息(含大量子类型) 50: 'voip', // VoIP 通话 10000: 'system', // 系统消息 10002: 'revoke', // 撤回消息 244813135921: 'quote', // 引用消息 266287972401: 'pat', // 拍一拍 81604378673: 'chat_record', // 聊天记录 8594229559345: 'red_packet', // 红包 8589934592049: 'transfer', // 转账};// Type49 子类型映射(对应 exportWorker.js 中 parseType49 的 XML type 字段)const TYPE49_SUBTYPES = { 2000: 'transfer', // 转账 2001: 'red_packet', // 红包 51: 'channels', // 视频号 57: 'quote', // 引用 87: 'group_notice', // 群公告 5: 'link', // 链接 33: 'miniapp', // 小程序 36: 'miniapp', // 小程序 4: 'file', // 文件 6: 'file', // 文件 8: 'image', // 图片(应用消息方式发送) 19: 'merged_forward', // 合并转发 40: 'collect', // 收藏 44: 'video', // 视频 50: 'voip', // 通话 76: 'collect', // 收藏 80: 'collect', // 收藏 2003: 'video_call', // 视频通话};/** * 检测并解码 hex 编码字符串 */function tryDecodeHex(str) { if (!str || typeof str !== 'string') return null; const trimmed = str.trim(); // 检查是否是合法的 hex 字符串(偶数长度,只含 0-9a-f) if (trimmed.length >= 2 && trimmed.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(trimmed)) { try { return Buffer.from(trimmed, 'hex').toString('utf8'); } catch (e) { return null; } } return null;}/** * 检测并解码 base64 编码字符串 */function tryDecodeBase64(str) { if (!str || typeof str !== 'string') return null; const trimmed = str.trim(); // Base64 基本模式检测 if (trimmed.length >= 4 && trimmed.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(trimmed)) { try { const decoded = Buffer.from(trimmed, 'base64').toString('utf8'); // 验证解码结果是否是合法的文本/JSON/XML if (decoded && /^[\x20-\x7e\u4e00-\u9fff\u3000-\u303f\n\r\t]/.test(decoded)) { return decoded; } } catch (e) { return null; } } return null;}/** * 检测字符串是否为可读文本 * 二进制数据(hex 解码后的 zstd/图片等)包含大量 U+FFFD 替换符或控制字符, * 直接输出会造成乱码,应判定为非文本并返回空字符串。 */function looksLikeText(str) { if (!str || typeof str !== 'string') return false; if (str.length === 0) return true; let badCount = 0; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); // 替换符 U+FFFD:UTF-8 解码失败 if (code === 0xFFFD) badCount++; // 控制字符(保留常见空白 \n \r \t) else if (code < 0x20 && code !== 0x09 && code !== 0x0A && code !== 0x0D) badCount++; } // 超过 10% 的坏字符视为二进制数据 return badCount / str.length < 0.10;}/** * 尝试用 zstd 解压数据 * 对应 exportWorker.js 中检测魔术字节 4247762216 后的 zstd 解压 */function tryZstdDecompress(buffer) { if (buffer.length >= 5) { // 检查 zstd 魔术字节 if (buffer.subarray(0, 5).equals(ZSTD_MAGIC)) { try { const decompressed = fzstd.decompress(buffer); return Buffer.from(decompressed).toString('utf8'); } catch (e) { console.warn('[Parser] zstd 解压失败:', e.message); } } // 也检查标准 zstd 魔术字节 0x28 0xB5 0x2F 0xFD if (buffer[0] === 0x28 && buffer[1] === 0xB5 && buffer[2] === 0x2F && buffer[3] === 0xFD) { try { const decompressed = fzstd.decompress(buffer); return Buffer.from(decompressed).toString('utf8'); } catch (e) { console.warn('[Parser] zstd(标准) 解压失败:', e.message); } } } return null;}/** * 解码消息内容 * 对应 exportWorker.js 中的 decodeMessageContent 函数 * * 优先级:compress_content -> message_content * 支持的编码:zstd 压缩 -> hex -> base64 -> 原始 UTF-8 */function decodeMessageContent(messageContent, compressContent) { // 1. 优先尝试 compress_content if (compressContent) { let content = compressContent; if (typeof content === 'string') { // 尝试作为 hex 解码(zstd 数据常常被 hex 编码存储) const hexDecoded = tryDecodeHex(content); if (hexDecoded) { // 检查是否包含 zstd 压缩数据 const buf = Buffer.from(content, 'hex'); const zstdResult = tryZstdDecompress(buf); if (zstdResult) return zstdResult; // 不是 zstd,仅当是可读文本时才返回,避免二进制乱码 return looksLikeText(hexDecoded) ? hexDecoded : ''; } // 可能直接是 base64 编码的 zstd 数据 if (/^[A-Za-z0-9+/]+={0,2}$/.test(content) && content.length >= 4 && content.length % 4 === 0) { try { const buf = Buffer.from(content, 'base64'); const zstdResult = tryZstdDecompress(buf); if (zstdResult) return zstdResult; } catch (e) {} } // 直接返回原始文本 return content; } if (Buffer.isBuffer(content)) { const zstdResult = tryZstdDecompress(content); if (zstdResult) return zstdResult; const text = content.toString('utf8'); return looksLikeText(text) ? text : ''; } } // 2. 尝试 message_content if (messageContent) { let content = messageContent; if (typeof content === 'string') { // 尝试 hex 解码 const hexDecoded = tryDecodeHex(content); if (hexDecoded) { // 关键修复:hex 解码后可能仍是 zstd 压缩数据(微信 4.x 将 // 压缩后的二进制以 hex 字符串存储于 message_content), // 必须先尝试 zstd 解压,否则会把压缩二进制直接当文本输出导致乱码 const buf = Buffer.from(content, 'hex'); const zstdResult = tryZstdDecompress(buf); if (zstdResult) return zstdResult; // 不是 zstd,仅当可读文本时才返回 return looksLikeText(hexDecoded) ? hexDecoded : ''; } // 尝试 base64 解码 const b64Decoded = tryDecodeBase64(content); if (b64Decoded) return b64Decoded; // 返回原始文本 return content; } if (Buffer.isBuffer(content)) { const zstdResult = tryZstdDecompress(content); if (zstdResult) return zstdResult; const text = content.toString('utf8'); return looksLikeText(text) ? text : ''; } } return '';}/** * 解析 XML 字符串为对象 */function parseXml(xmlStr) { if (!xmlStr || typeof xmlStr !== 'string') return null; // 简单 XML 解析器(不依赖外部库) // 微信消息 XML 结构相对简单,用正则提取关键字段即可 try { const result = {}; // 提取标签和文本 const regex = /<(\w+)[^>]*>([\s\S]*?)<\/\1>/g; let match; while ((match = regex.exec(xmlStr)) !== null) { const tag = match[1]; const value = match[2].trim(); // 递归解析嵌套 XML if (value.includes('<') && value.includes('>')) { result[tag] = parseXml(value) || value; } else { result[tag] = value; } } return Object.keys(result).length > 0 ? result : null; } catch (e) { return null; }}/** * 提取 XML 中指定标签的属性值 */function extractXmlAttr(xmlStr, tag, attr) { const regex = new RegExp(`<${tag}[^>]*\\s${attr}="([^"]*)"`, 'i'); const match = xmlStr.match(regex); return match ? match[1] : null;}/** * 提取 XML 中指定标签的文本内容 */function extractXmlText(xmlStr, tag) { const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i'); const match = xmlStr.match(regex); return match ? match[1].trim() : null;}/** * 解析 Type49 应用消息 * 对应 exportWorker.js 中的 parseType49 函数 * * @Param {string} xmlContent - 消息 XML 内容 * @returns {{subtype: string, title: string, description: string, url: string, ...}} */function parseType49(xmlContent) { const parsed = { subtype: 'unknown', title: '', description: '', url: '', thumbUrl: '', appName: '', type: 0 }; // 提取 appmsg type 字段 const typeStr = extractXmlText(xmlContent, 'type'); if (typeStr) { parsed.type = parseInt(typeStr, 10); parsed.subtype = TYPE49_SUBTYPES[parsed.type] || 'unknown'; } // 提取标题 parsed.title = extractXmlText(xmlContent, 'title') || ''; // 提取描述 parsed.description = extractXmlText(xmlContent, 'des') || ''; // 提取 URL parsed.url = extractXmlText(xmlContent, 'url') || ''; // 提取缩略图 parsed.thumbUrl = extractXmlText(xmlContent, 'thumb') || ''; // 提取来源应用名 parsed.appName = extractXmlText(xmlContent, 'appname') || extractXmlText(xmlContent, 'sourcedisplayname') || ''; // 如果是引用消息(type=57),解析被引用内容 if (parsed.type === 57) { const refContent = extractXmlText(xmlContent, 'refcontent'); if (refContent) { parsed.quoteContent = refContent; } } // 如果是文件(type=4/6),解析文件信息 if (parsed.type === 4 || parsed.type === 6) { parsed.fileName = extractXmlText(xmlContent, 'title') || ''; parsed.fileSize = extractXmlAttr(xmlContent, 'appmsg', 'file_size') || ''; } // 如果是转账(type=2000) if (parsed.type === 2000) { parsed.feedesc = extractXmlText(xmlContent, 'feedesc') || ''; parsed.transType = extractXmlText(xmlContent, 'type') || ''; } // 如果是红包(type=2001) if (parsed.type === 2001) { parsed.wishing = extractXmlText(xmlContent, 'wishing') || ''; parsed.feedesc = extractXmlText(xmlContent, 'feedesc') || ''; } return parsed;}/** * 去除消息内容中的发送者前缀 * 微信群消息中,message_content 常以 "wxid_xxx:\n" 开头 */function stripSenderPrefix(content, isGroup) { if (!content || !isGroup) return content; // 匹配 "wxid_xxx:\n" 或 "nickname:\n" 前缀 const match = content.match(/^([^:]+):\n([\s\S]*)$/); if (match) { return match[2]; } return content;}/** * 图片格式验证 * 对应 exportWorker.js 中解密后检查文件头魔术字节 */function detectImageFormat(buffer) { if (!buffer || buffer.length < 4) return 'unknown'; // JPEG: FF D8 FF if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) return 'jpeg'; // PNG: 89 50 4E 47 if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) return 'png'; // GIF: 47 49 46 if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return 'gif'; // RIFF (WEBP): 52 49 46 46 if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) return 'webp'; // wxgf (微信加密图片): 77 78 67 66 if (buffer[0] === 0x77 && buffer[1] === 0x78 && buffer[2] === 0x67 && buffer[3] === 0x66) return 'wxgf'; return 'unknown';}/** * 规范化消息类型码 * 微信 4.x 的 local_type 是复合类型码:(subType << 32) | mainType * 例如 219043332145 = (51 << 32) | 49,主类型是 49(应用消息),子类型是 51(视频号) * 需要取低 32 位获得主类型码 * 同时处理值可能为字符串的情况 */function normalizeTypeCode(typeValue) { if (typeValue === undefined || typeValue === null) return 0; const num = typeof typeValue === 'string' ? parseInt(typeValue, 10) : typeValue; if (isNaN(num)) return 0; // 如果是很大的数(超过 32 位范围),取低 32 位 if (num > 0xFFFFFFFF) { return num & 0xFFFFFFFF; } return num;}/** * 获取子类型码(复合类型码的高 32 位) */function getSubTypeCode(typeValue) { if (typeValue === undefined || typeValue === null) return 0; const num = typeof typeValue === 'string' ? parseInt(typeValue, 10) : typeValue; if (isNaN(num) || num <= 0xFFFFFFFF) return 0; return Math.floor(num / 0x100000000) & 0xFFFFFFFF;}/** * 从微信 4.x 复合类型码中提取主类型 * 微信 4.x 的 local_type 是 64 位整数,格式为 (subType << 32) | mainType * 例如 219043332145 = (51 << 32) | 49,主类型为 49(应用消息) */function getMainType(typeCode) { const num = typeof typeCode === 'string' ? parseInt(typeCode, 10) : typeCode; if (isNaN(num)) return 0; // 取低 32 位 return num & 0xFFFFFFFF;}/** * 解析单条消息为可读格式 * * @param {Object} rawMsg - 数据库原始消息对象(所有字段可能为字符串) * @param {boolean} isGroup - 是否群聊 * @returns {Object} 解析后的消息对象 */function parseMessage(rawMsg, isGroup = false) { // 微信 4.x: 所有值都是字符串,需转换 const localId = parseInt(rawMsg.local_id || rawMsg.localId || '0', 10) || 0; const serverId = rawMsg.server_id || rawMsg.serverId || rawMsg.svrid || ''; const rawType = rawMsg.local_type || rawMsg.type || '0'; const mainType = getMainType(rawType); const typeName = MESSAGE_TYPES[mainType] || 'unknown'; const timestamp = parseInt(rawMsg.create_time || rawMsg.timestamp || '0', 10) || 0; const isSendVal = parseInt(rawMsg.is_send !== undefined ? rawMsg.is_send : (rawMsg.isSend || '0'), 10); const parsed = { localId: localId, serverId: serverId, type: mainType, rawType: rawType, typeName: typeName, timestamp: timestamp, time: formatTimestamp(timestamp), isSend: isSendVal === 1, sender: rawMsg.sender_username || rawMsg.sender || '', sortSeq: parseInt(rawMsg.sort_seq || '0', 10) || 0, content: '', // 解析后的文本内容 rawContent: '', // 原始解码内容 extra: {} // 额外信息(图片URL/文件名等) }; // 获取消息类型码(已规范化) // typeCode 和 typeName 已在上方计算 // 解码消息内容 const rawContent = decodeMessageContent( rawMsg.message_content || rawMsg.messageContent, rawMsg.compress_content || rawMsg.compressContent ); parsed.rawContent = rawContent; // 根据消息类型提取可读内容 switch (typeName) { case 'text': parsed.content = stripSenderPrefix(rawContent, isGroup); break; case 'image': parsed.content = '[图片]'; break; case 'voice': parsed.content = '[语音消息]'; if (rawMsg.voice_length !== undefined) { parsed.extra.duration = Math.ceil(rawMsg.voice_length / 1000); } break; case 'video': parsed.content = '[视频]'; break; case 'emoji': parsed.content = '[动画表情]'; break; case 'card': parsed.content = '[名片]'; break; case 'location': parsed.content = '[位置]'; break; case 'voip': parsed.content = '[通话]'; break; case 'system': parsed.content = stripSenderPrefix(rawContent, isGroup) || '[系统消息]'; break; case 'revoke': parsed.content = '[撤回了一条消息]'; break; case 'pat': parsed.content = '[拍一拍]'; break; case 'quote': case 'chat_record': // 这类消息内容为 XML,尝试解析 if (rawContent.startsWith('<')) { const xml = parseXml(rawContent); if (xml) { parsed.content = extractXmlText(rawContent, 'title') || extractXmlText(rawContent, 'content') || '[引用/聊天记录]'; parsed.extra = xml; } else { parsed.content = '[引用/聊天记录]'; } } else { parsed.content = rawContent || '[引用/聊天记录]'; } break; case 'red_packet': parsed.content = '[红包]'; if (rawContent.startsWith('<')) { const appMsg = parseType49(rawContent); parsed.extra.wishing = appMsg.wishing; parsed.extra.feedesc = appMsg.feedesc; if (appMsg.wishing) parsed.content = `[红包: ${appMsg.wishing}]`; } break; case 'transfer': parsed.content = '[转账]'; if (rawContent.startsWith('<')) { const appMsg = parseType49(rawContent); parsed.extra.feedesc = appMsg.feedesc; if (appMsg.feedesc) parsed.content = `[转账: ${appMsg.feedesc}]`; } break; case 'app': // Type49 应用消息,最复杂的一种 if (rawContent.startsWith('<')) { const appMsg = parseType49(rawContent); parsed.extra = appMsg; switch (appMsg.subtype) { case 'transfer': parsed.content = appMsg.feedesc ? `[转账: ${appMsg.feedesc}]` : '[转账]'; parsed.typeName = 'transfer'; break; case 'red_packet': parsed.content = appMsg.wishing ? `[红包: ${appMsg.wishing}]` : '[红包]'; parsed.typeName = 'red_packet'; break; case 'channels': parsed.content = `[视频号] ${appMsg.title}`; break; case 'quote': parsed.content = `[引用] ${appMsg.quoteContent || appMsg.title || ''}`; parsed.typeName = 'quote'; break; case 'group_notice': parsed.content = `[群公告] ${appMsg.title}`; break; case 'link': parsed.content = `[链接] ${appMsg.title}`; parsed.extra.url = appMsg.url; break; case 'miniapp': parsed.content = `[小程序] ${appMsg.title}`; break; case 'file': parsed.content = `[文件] ${appMsg.fileName || appMsg.title}`; break; case 'merged_forward': parsed.content = `[合并转发] ${appMsg.title}`; break; case 'voip': case 'video_call': parsed.content = '[通话]'; break; case 'collect': parsed.content = `[收藏] ${appMsg.title}`; break; default: parsed.content = appMsg.title ? `[应用消息] ${appMsg.title}` : '[应用消息]'; } } else { parsed.content = '[应用消息]'; } break; default: parsed.content = rawContent ? rawContent.substring(0, 200) : '[未知消息类型]'; } return parsed;}/** * 批量解析消息 */function parseMessages(rawMessages, isGroup = false) { return rawMessages.map(msg => parseMessage(msg, isGroup));}/** * 时间戳格式化 */function formatTimestamp(ts) { if (!ts) return ''; const num = typeof ts === 'string' ? parseInt(ts, 10) : ts; if (!num || num <= 0) return ''; const date = new Date(num * 1000); const pad = n => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;}module.exports = { parseMessage, parseMessages, decodeMessageContent, parseType49, detectImageFormat, normalizeTypeCode, getSubTypeCode, MESSAGE_TYPES, TYPE49_SUBTYPES}; |
导出:
|
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
|
/** * exporter.js * 消息导出模块 * * 支持将解析后的微信消息导出为多种格式: * - HTML: 聊天气泡布局,适合阅读和打印 * - CSV: 表格格式,适合数据处理 * - JSON: 完整结构化数据,适合程序处理 * - TXT: 纯文本格式,简单易用 */const fs = require('fs');const path = require('path');/** * HTML 导出器 * 对应 exportWorker.js 中的 HTML 导出逻辑(聊天气泡布局) */class HtmlExporter { constructor() { this.css = ` * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #f5f5f5; color: #333; } .container { max-width: 800px; margin: 0 auto; background: #fff; min-height: 100vh; } .header { background: #ededed; padding: 20px; border-bottom: 1px solid #dcdcdc; text-align: center; } .header h1 { font-size: 18px; color: #191919; } .header .meta { font-size: 12px; color: #888; margin-top: 5px; } .chat-list { padding: 20px; } .message { display: flex; margin-bottom: 15px; align-items: flex-start; } .message.self { flex-direction: row-reverse; } .avatar { width: 40px; height: 40px; border-radius: 4px; background: #d3d3d3; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #666; margin: 0 10px; } .bubble-wrap { max-width: 70%; } .sender { font-size: 12px; color: #888; margin-bottom: 3px; } .message.self .sender { text-align: right; } .bubble { padding: 10px 14px; border-radius: 4px; font-size: 14px; line-height: 1.5; word-break: break-all; position: relative; } .message.other .bubble { background: #fff; border: 1px solid #e5e5e5; } .message.self .bubble { background: #95ec69; } .system-msg { text-align: center; margin: 10px 0; } .system-msg span { background: #dcdcdc; padding: 3px 10px; border-radius: 3px; font-size: 12px; color: #666; } .time-divider { text-align: center; margin: 20px 0; } .time-divider span { font-size: 12px; color: #999; } .msg-meta { font-size: 11px; color: #aaa; margin-top: 2px; } .message.self .msg-meta { text-align: right; } .type-tag { display: inline-block; padding: 1px 5px; border-radius: 2px; font-size: 11px; color: #fff; margin-right: 4px; } .type-tag.text { background: #07c160; } .type-tag.image { background: #fa9d3b; } .type-tag.voice { background: #5e9eff; } .type-tag.video { background: #ff5d5d; } .type-tag.app { background: #9b59b6; } .type-tag.system { background: #999; } `; } /** * 导出为 HTML 文件 */ export(messages, sessionInfo, outputPath) { const html = this.generateHtml(messages, sessionInfo); fs.writeFileSync(outputPath, html, 'utf8'); console.log(`[HTML] 已导出 ${messages.length} 条消息到 ${outputPath}`); return outputPath; } generateHtml(messages, sessionInfo) { const title = sessionInfo.displayName || sessionInfo.sessionId || '聊天记录'; const now = new Date().toLocaleString('zh-CN'); let html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${title} - 聊天记录</title><style>${this.css}</style></head><body><div class="container"> <div class="header"> <h1>${title}</h1> <div class="meta">导出时间: ${now} | 消息数: ${messages.length}</div> </div> <div class="chat-list">`; let lastDate = ''; for (const msg of messages) { // 时间分割线(超过 5 分钟差显示时间) const msgDate = msg.time ? msg.time.split(' ')[0] : ''; if (msgDate && msgDate !== lastDate) { html += ` <div class="time-divider"><span>${msgDate}</span></div>\n`; lastDate = msgDate; } // 系统消息居中显示 if (msg.typeName === 'system' || msg.typeName === 'revoke' || msg.typeName === 'pat') { html += ` <div class="system-msg"><span>${this.escapeHtml(msg.content)}</span></div>\n`; continue; } const isSelf = msg.isSend ? 'self' : 'other'; const senderName = msg.isSend ? '我' : (msg.senderName || msg.sender || '对方'); const avatarChar = senderName.charAt(0) || '?'; const typeTag = msg.typeName !== 'text' ? `<span class="type-tag ${msg.typeName}">${msg.typeName}</span>` : ''; html += ` <div class="message ${isSelf}"> <div class="avatar">${this.escapeHtml(avatarChar)}</div> <div class="bubble-wrap"> <div class="sender">${this.escapeHtml(senderName)}</div> <div class="bubble">${typeTag}${this.escapeHtml(msg.content)}</div> <div class="msg-meta">${msg.time || ''}</div> </div> </div>\n`; } html += ` </div></div></body></html>`; return html; } escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }}/** * JSON 导出器 * 对应 exportWorker.js 中的 JSON 导出 */class JsonExporter { export(messages, sessionInfo, outputPath) { const data = { session: sessionInfo, exportTime: new Date().toISOString(), messageCount: messages.length, messages: messages }; fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf8'); console.log(`[JSON] 已导出 ${messages.length} 条消息到 ${outputPath}`); return outputPath; }}/** * CSV 导出器 * 对应 exportWorker.js 中的 CSV/Excel 导出逻辑(简化版用 CSV) */class CsvExporter { export(messages, sessionInfo, outputPath) { const headers = ['时间', '发送者', '方向', '消息类型', '内容', '原始内容', 'Server ID']; const rows = [headers.join(',')]; for (const msg of messages) { const row = [ this.csvField(msg.time || ''), this.csvField(msg.isSend ? '我' : (msg.senderName || msg.sender || '')), this.csvField(msg.isSend ? '发送' : '接收'), this.csvField(msg.typeName || ''), this.csvField(msg.content || ''), this.csvField(msg.rawContent || ''), this.csvField(msg.serverId || '') ]; rows.push(row.join(',')); } fs.writeFileSync(outputPath, '\ufeff' + rows.join('\n'), 'utf8'); console.log(`[CSV] 已导出 ${messages.length} 条消息到 ${outputPath}`); return outputPath; } csvField(str) { if (!str) return '""'; // CSV 转义:双引号包裹,内部双引号转义 const escaped = String(str).replace(/"/g, '""'); return `"${escaped}"`; }}/** * TXT 导出器 */class TxtExporter { export(messages, sessionInfo, outputPath) { const lines = []; const title = sessionInfo.displayName || sessionInfo.sessionId || '聊天记录'; lines.push(`========================================`); lines.push(` ${title}`); lines.push(` 导出时间: ${new Date().toLocaleString('zh-CN')}`); lines.push(` 消息数: ${messages.length}`); lines.push(`========================================\n`); let lastDate = ''; for (const msg of messages) { const msgDate = msg.time ? msg.time.split(' ')[0] : ''; if (msgDate && msgDate !== lastDate) { lines.push(`\n--- ${msgDate} ---\n`); lastDate = msgDate; } const sender = msg.isSend ? '我' : (msg.senderName || msg.sender || '对方'); const time = msg.time ? msg.time.split(' ')[1] : ''; lines.push(`[${time}] ${sender}: ${msg.content}`); // 对非文本消息,附加原始内容 if (msg.typeName !== 'text' && msg.rawContent && msg.rawContent !== msg.content) { const preview = msg.rawContent.substring(0, 100); lines.push(` [原始内容] ${preview}${msg.rawContent.length > 100 ? '...' : ''}`); } } fs.writeFileSync(outputPath, lines.join('\n'), 'utf8'); console.log(`[TXT] 已导出 ${messages.length} 条消息到 ${outputPath}`); return outputPath; }}/** * 统一导出入口 * * @param {Array} messages - 解析后的消息数组 * @param {Object} sessionInfo - 会话信息 {sessionId, displayName, ...} * @param {string} outputDir - 输出目录 * @param {string|string[]} formats - 导出格式 ('html', 'csv', 'json', 'txt' 或其数组) * @returns {string[]} 生成的文件路径列表 */function exportMessages(messages, sessionInfo, outputDir, formats = ['html', 'json', 'txt']) { // 确保输出目录存在 fs.mkdirSync(outputDir, { recursive: true }); const formatList = Array.isArray(formats) ? formats : [formats]; const baseName = sessionInfo.displayName || sessionInfo.sessionId || 'chat'; // 清理文件名中的非法字符 const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_'); const exporters = { html: new HtmlExporter(), csv: new CsvExporter(), json: new JsonExporter(), txt: new TxtExporter() }; const outputFiles = []; for (const fmt of formatList) { const exporter = exporters[fmt]; if (!exporter) { console.warn(`[Export] 不支持的格式: ${fmt}`); continue; } const outputPath = path.join(outputDir, `${safeName}.${fmt}`); exporter.export(messages, sessionInfo, outputPath); outputFiles.push(outputPath); } return outputFiles;}module.exports = { exportMessages, HtmlExporter, CsvExporter, JsonExporter, TxtExporter}; |
测试一次性导出所有信息 其中图片和视频以及其他复杂媒体未做详细解析 :
|
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
|
/** * export_all.js — 完整微信聊天记录导出脚本 (v2) * * * 1. 联系人名称:用 wcdb_get_contacts_compact([]) 获取全部 联系人 * 2. 群成员昵称:用 wcdb_get_group_nicknames 获取每个群的成员昵称映射 * 3. 多消息库:用 wcdb_list_message_dbs 检测并遍历所有 message_*.db * * * 用法: * node src/export_all.js [--top 5] [--format html,json,txt] */const koffi = require('koffi');const path = require('path');const fs = require('fs');const BASE = 'D:\\tmp\\wx-export';const WCDB_DLL_DIR = path.join(BASE, 'libs', 'wcdb');const RUNTIME_DIR = path.join(BASE, 'libs', 'runtime');const KEY_FILE = path.join(BASE, '.temp', 'verified_key.txt');const DB_DIR = path.join(BASE, '.temp', 'dbs');const OUTPUT_DIR = path.join(BASE, 'output');const WXID = 'wxid_q9tvnagk60zg22';// 解析命令行参数const args = process.argv.slice(2);let topN = 0;let formats = ['html', 'txt'];for (let i = 0; i < args.length; i++) { if (args[i] === '--top' && args[i + 1]) { topN = parseInt(args[i + 1], 10); i++; } else if (args[i] === '--format' && args[i + 1]) { formats = args[i + 1].split(',').map(s => s.trim()); i++; }}const { parseMessage } = require('./messageParser');const { exportMessages } = require('./exporter');// 设置 DLL 路径[WCDB_DLL_DIR, RUNTIME_DIR].forEach(dir => { if (fs.existsSync(dir)) process.env.PATH = dir + ';' + process.env.PATH;});let lib = null;let dllFuncs = {};let wcdbInitialized = false;function initDll() { if (lib) return; try { koffi.load(path.join(RUNTIME_DIR, 'msvcp140.dll')); } catch (e) {} try { koffi.load(path.join(RUNTIME_DIR, 'vcruntime140.dll')); } catch (e) {} try { koffi.load(path.join(RUNTIME_DIR, 'vcruntime140_1.dll')); } catch (e) {} try { koffi.load(path.join(RUNTIME_DIR, 'msvcp140_1.dll')); } catch (e) {} koffi.load(path.join(WCDB_DLL_DIR, 'WCDB.dll')); koffi.load(path.join(WCDB_DLL_DIR, 'SDL2.dll')); lib = koffi.load(path.join(WCDB_DLL_DIR, 'wcdb_api.dll')); const f = dllFuncs; f.wcdb_init = lib.func('int32_t wcdb_init()'); f.wcdb_shutdown = lib.func('int32_t wcdb_shutdown()'); f.wcdb_open_account = lib.func('int32_t wcdb_open_account(const char *path, const char *key, _Out_ int64_t *handle)'); f.wcdb_close_account = lib.func('int32_t wcdb_close_account(int64_t handle)'); f.wcdb_set_my_wxid = lib.func('int32_t wcdb_set_my_wxid(int64_t handle, const char *wxid)'); f.wcdb_get_sessions = lib.func('int32_t wcdb_get_sessions(int64_t handle, _Out_ const char **outJson)'); f.wcdb_get_messages = lib.func('int32_t wcdb_get_messages(int64_t handle, const char *username, int32_t limit, int32_t offset, _Out_ const char **outJson)'); f.wcdb_get_message_count = lib.func('int32_t wcdb_get_message_count(int64_t handle, const char *username, _Out_ int32_t *outCount)'); f.wcdb_get_display_names = lib.func('int32_t wcdb_get_display_names(int64_t handle, const char *usernamesJson, _Out_ const char **outJson)'); f.wcdb_get_contacts_compact = lib.func('int32_t wcdb_get_contacts_compact(int64_t handle, const char *usernamesJson, _Out_ const char **outJson)'); f.wcdb_get_group_nicknames = lib.func('int32_t wcdb_get_group_nicknames(int64_t handle, const char *chatroomId, _Out_ const char **outJson)'); f.wcdb_list_message_dbs = lib.func('int32_t wcdb_list_message_dbs(int64_t handle, _Out_ const char **outJson)'); // 不声明 wcdb_free_string — koffi const char** 模式下会导致进程崩溃 (double-free) console.log('[DLL] 函数声明完成');}function callJsonFunc(fn, ...args) { const out = [null]; const rc = fn(...args, out); if (rc !== 0) return { rc, data: null }; if (out[0]) { try { const jsonStr = out[0]; const fixedStr = jsonStr.replace(/"server_id":\s*(-?\d{16,})/g, '"server_id":"$1"'); return { rc: 0, data: JSON.parse(fixedStr) }; } catch (e) { console.error('[JSON] 解析失败:', e.message); return { rc: 0, data: null }; } } return { rc: 0, data: null };}function openDb(dbPath, key) { if (!wcdbInitialized) { dllFuncs.wcdb_init(); wcdbInitialized = true; } const handle = [0]; const rc = dllFuncs.wcdb_open_account(dbPath, key, handle); if (rc !== 0 || handle[0] === 0) { throw new Error(`打开数据库失败: ${dbPath} rc=${rc}`); } try { dllFuncs.wcdb_set_my_wxid(handle[0], WXID); } catch (e) {} return handle[0];}function isGroupSession(sessionId) { return sessionId && sessionId.endsWith('@chatroom');}/** * 从 contacts_compact 结果中提取显示名称 * 优先级:remark > nick_name > alias > username */function pickDisplayName(contact) { if (!contact) return null; return contact.remark || contact.nick_name || contact.nickname || contact.alias || contact.user_name || contact.username || null;}async function main() { console.log('============================================'); console.log(' 微信聊天记录导出'); console.log('============================================\n'); const key = fs.readFileSync(KEY_FILE, 'utf8').trim(); console.log(`[Key] ${key.substring(0, 8)}...${key.substring(56)}`); initDll(); // === 1. session.db → 会话列表 === const sHandle = openDb(path.join(DB_DIR, 'session.db'), key); const { data: sessions } = callJsonFunc(dllFuncs.wcdb_get_sessions, sHandle); const sessionArr = Array.isArray(sessions) ? sessions : (sessions ? [sessions] : []); console.log(`[Session] 共 ${sessionArr.length} 个会话`); dllFuncs.wcdb_close_account(sHandle); // === 2. contact.db → 全部联系人 + 群成员昵称 === console.log(`\n[Contact] 打开 contact.db...`); const cHandle = openDb(path.join(DB_DIR, 'contact.db'), key); // 2a. 获取全部联系人(传空数组返回全部) const allContactMap = {}; // username → displayName const { data: allContacts } = callJsonFunc(dllFuncs.wcdb_get_contacts_compact, cHandle, JSON.stringify([])); if (allContacts) { const arr = Array.isArray(allContacts) ? allContacts : [allContacts]; for (const c of arr) { const name = pickDisplayName(c); const uid = c.username || c.user_name; if (uid && name) { allContactMap[uid] = name; } } } console.log(`[Contact] 全部联系人: ${Object.keys(allContactMap).length} 个`); // 2b. 获取每个群聊的成员昵称映射 const groupNicknameMap = {}; // groupId → { memberWxid: nickname } const groupIds = sessionArr.map(s => s.username).filter(u => u && u.endsWith('@chatroom')); for (const gid of groupIds) { const { data: nicknames } = callJsonFunc(dllFuncs.wcdb_get_group_nicknames, cHandle, gid); if (nicknames) { // 返回的是 { memberWxid: nickname } 对象 groupNicknameMap[gid] = nicknames; } } console.log(`[Contact] 群成员昵称: ${Object.keys(groupNicknameMap).length} 个群`); dllFuncs.wcdb_close_account(cHandle); // === 3. message db → 检测所有消息库 === console.log(`\n[Message] 检测消息数据库...`); const mHandle0 = openDb(path.join(DB_DIR, 'message_0.db'), key); const { data: msgDbs } = callJsonFunc(dllFuncs.wcdb_list_message_dbs, mHandle0); let messageDbPaths = []; if (msgDbs) { messageDbPaths = Array.isArray(msgDbs) ? msgDbs : [msgDbs]; } console.log(`[Message] 消息数据库: ${messageDbPaths.length} 个`); for (const dbp of messageDbPaths) { console.log(` → ${typeof dbp === 'string' ? dbp : JSON.stringify(dbp)}`); } dllFuncs.wcdb_close_account(mHandle0); // === 4. 统计每个会话的消息数(跨所有 message db) === console.log(`\n[Message] 统计各会话消息数...`); const sessionMsgCountMap = {}; // sessionId → total count for (const dbPath of messageDbPaths) { const dbFile = typeof dbPath === 'string' ? dbPath : (dbPath.path || dbPath.name || ''); // 如果路径是 .temp/dbs 下的,直接用;否则尝试在 DB_DIR 下找 let fullPath = dbFile; if (!fs.existsSync(fullPath)) { const basename = path.basename(dbFile); fullPath = path.join(DB_DIR, basename); } if (!fs.existsSync(fullPath)) { console.log(` 跳过不存在的: ${dbFile}`); continue; } const mHandle = openDb(fullPath, key); for (const s of sessionArr) { const sid = s.username; if (!sid) continue; const cntOut = [0]; const rc = dllFuncs.wcdb_get_message_count(mHandle, sid, cntOut); if (rc === 0 && cntOut[0] > 0) { sessionMsgCountMap[sid] = (sessionMsgCountMap[sid] || 0) + cntOut[0]; } } dllFuncs.wcdb_close_account(mHandle); } // 构建会话信息列表 const sessionInfos = []; for (const s of sessionArr) { const sid = s.username; if (!sid) continue; const count = sessionMsgCountMap[sid] || 0; if (count > 0) { // 优先用 contacts_compact 的名称,其次用 session 自带的 last_sender_display_name const displayName = allContactMap[sid] || s.last_sender_display_name || sid; sessionInfos.push({ sessionId: sid, displayName: displayName, messageCount: count, isGroup: isGroupSession(sid), summary: s.summary || '', raw: s }); } } sessionInfos.sort((a, b) => b.messageCount - a.messageCount); console.log(`[Message] 有消息的会话: ${sessionInfos.length} 个\n`); console.log('--- 会话列表 (按消息数排序) ---'); for (let i = 0; i < Math.min(20, sessionInfos.length); i++) { const si = sessionInfos[i]; console.log(` [${i + 1}] ${si.displayName} (${si.messageCount}条) ${si.isGroup ? '[群]' : ''}`); } console.log(''); // === 5. 逐个会话读取消息并导出 === const toExport = topN > 0 ? sessionInfos.slice(0, topN) : sessionInfos; console.log(`[Export] 将导出 ${toExport.length} 个会话 (${topN > 0 ? 'Top ' + topN : '全部'})`); console.log(`[Export] 格式: ${formats.join(', ')}\n`); fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const exportSummary = []; let totalMessages = 0; for (let idx = 0; idx < toExport.length; idx++) { const si = toExport[idx]; console.log(`[${idx + 1}/${toExport.length}] ${si.displayName} (${si.messageCount}条)...`); try { // 从所有 message db 读取该会话的消息 const allRawMessages = []; const PAGE_SIZE = 500; for (const dbPath of messageDbPaths) { const dbFile = typeof dbPath === 'string' ? dbPath : (dbPath.path || dbPath.name || ''); let fullPath = dbFile; if (!fs.existsSync(fullPath)) { const basename = path.basename(dbFile); fullPath = path.join(DB_DIR, basename); } if (!fs.existsSync(fullPath)) continue; const mHandle = openDb(fullPath, key); // 先检查这个 db 里有多少条 const cntOut = [0]; const rc = dllFuncs.wcdb_get_message_count(mHandle, si.sessionId, cntOut); if (rc !== 0 || cntOut[0] === 0) { dllFuncs.wcdb_close_account(mHandle); continue; } let offset = 0; while (offset < cntOut[0]) { const limit = Math.min(PAGE_SIZE, cntOut[0] - offset); const { data: msgData } = callJsonFunc( dllFuncs.wcdb_get_messages, mHandle, si.sessionId, limit, offset ); if (!msgData) break; const msgArr = Array.isArray(msgData) ? msgData : (msgData.data || msgData.messages || [msgData]); if (msgArr.length === 0) break; allRawMessages.push(...msgArr); offset += msgArr.length; if (msgArr.length < limit) break; } dllFuncs.wcdb_close_account(mHandle); } if (allRawMessages.length === 0) { console.log(` → 无消息,跳过`); exportSummary.push({ name: si.displayName, count: 0, status: 'empty' }); continue; } // 获取该群的成员昵称映射 let groupNicks = null; if (si.isGroup && groupNicknameMap[si.sessionId]) { groupNicks = groupNicknameMap[si.sessionId]; } // 解析消息 const parsedMessages = allRawMessages.map(m => { const parsed = parseMessage(m, si.isGroup); // 填充发送者显示名称 if (!parsed.isSend && parsed.sender) { // 优先用群昵称,其次用全局联系人名称 if (groupNicks && groupNicks[parsed.sender]) { parsed.senderName = groupNicks[parsed.sender]; } else if (allContactMap[parsed.sender]) { parsed.senderName = allContactMap[parsed.sender]; } else { parsed.senderName = parsed.sender; } } return parsed; }); parsedMessages.sort((a, b) => a.timestamp - b.timestamp); totalMessages += parsedMessages.length; const sessionInfo = { sessionId: si.sessionId, displayName: si.displayName, isGroup: si.isGroup, summary: si.summary }; const files = exportMessages(parsedMessages, sessionInfo, OUTPUT_DIR, formats); console.log(` → ${parsedMessages.length}条 → ${files.map(f => path.basename(f)).join(', ')}`); exportSummary.push({ name: si.displayName, count: parsedMessages.length, status: 'ok', files: files }); } catch (e) { console.log(` → 出错: ${e.message}`); exportSummary.push({ name: si.displayName, count: 0, status: 'error: ' + e.message }); } } if (wcdbInitialized) { dllFuncs.wcdb_shutdown(); } const summaryPath = path.join(OUTPUT_DIR, '_导出汇总.json'); const summaryData = { exportTime: new Date().toISOString(), version: 'v2', totalSessions: toExport.length, totalMessages: totalMessages, formats: formats, contactCount: Object.keys(allContactMap).length, groupNicknameCount: Object.keys(groupNicknameMap).length, messageDbCount: messageDbPaths.length, sessions: exportSummary }; fs.writeFileSync(summaryPath, JSON.stringify(summaryData, null, 2), 'utf8'); console.log(`\n============================================`); console.log(` 导出完成!`); console.log(` 会话数: ${toExport.length}`); console.log(` 消息总数: ${totalMessages}`); console.log(` 输出目录: ${OUTPUT_DIR}`); console.log(`============================================`);}main().catch(err => { console.error('FATAL:', err.message); console.error(err.stack); try { if (wcdbInitialized) dllFuncs.wcdb_shutdown(); } catch (e) {} process.exit(1);}); |












暂无评论内容