weflow-cli 1.6.1 → 1.6.2
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/CHANGELOG.md +18 -0
- package/README.md +1 -0
- package/bin/weflow-cli.ts +36 -9
- package/dist/bin/weflow-cli.js +38 -9
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +4 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/chatService.d.ts +7 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +15 -5
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +15 -3
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -1
- package/dist/src/utils/pythonProcessEnv.js +16 -1
- package/dist/src/utils/pythonProcessEnv.js.map +1 -1
- package/docs/HEALTH-CHECK.md +98 -0
- package/docs/RELEASING.md +177 -124
- package/package.json +1 -1
- package/scripts/biz_daily.py +3 -1
- package/scripts/export_chat_html.py +9 -0
- package/scripts/health_check.py +336 -0
- package/scripts/nt_decrypt.py +1352 -1216
- package/scripts/watch_issues.py +212 -0
- package/scripts/watch_mail.py +131 -0
- package/src/core/ntCore.ts +4 -0
- package/src/services/chatService.ts +14 -5
- package/src/services/exportService.ts +15 -3
- package/src/utils/pythonProcessEnv.ts +18 -2
package/scripts/nt_decrypt.py
CHANGED
|
@@ -1,1216 +1,1352 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
WeChat NT (4.x) Database Access Tool
|
|
4
|
-
Uses sqlcipher3 to decrypt and query NT-format databases.
|
|
5
|
-
"""
|
|
6
|
-
import sys
|
|
7
|
-
import os
|
|
8
|
-
import json
|
|
9
|
-
import re
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
('
|
|
40
|
-
('
|
|
41
|
-
('
|
|
42
|
-
('
|
|
43
|
-
('
|
|
44
|
-
('
|
|
45
|
-
('
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
ReadProcessMemory
|
|
54
|
-
ReadProcessMemory.
|
|
55
|
-
|
|
56
|
-
VirtualQueryEx
|
|
57
|
-
VirtualQueryEx.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
os.path.expandvars(r'%USERPROFILE%\
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
os.path.join(home, 'xwechat_files'),
|
|
308
|
-
os.path.join(home, '
|
|
309
|
-
os.path.join(home, '
|
|
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
|
-
c
|
|
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
|
-
c
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
))
|
|
722
|
-
if not
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
"""
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
'
|
|
777
|
-
|
|
778
|
-
if not
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
#
|
|
794
|
-
|
|
795
|
-
if
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
"""
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
if
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
if
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
WeChat NT (4.x) Database Access Tool
|
|
4
|
+
Uses sqlcipher3 to decrypt and query NT-format databases.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import hashlib
|
|
11
|
+
import ctypes
|
|
12
|
+
from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
sqlcipher = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def require_sqlcipher():
|
|
19
|
+
"""Load SQLCipher only for database operations, not path discovery."""
|
|
20
|
+
global sqlcipher
|
|
21
|
+
if sqlcipher is not None:
|
|
22
|
+
return sqlcipher
|
|
23
|
+
try:
|
|
24
|
+
from sqlcipher3 import dbapi2 as sqlcipher_module
|
|
25
|
+
except ImportError as error:
|
|
26
|
+
raise RuntimeError("需要 sqlcipher3: pip install sqlcipher3") from error
|
|
27
|
+
sqlcipher = sqlcipher_module
|
|
28
|
+
return sqlcipher
|
|
29
|
+
|
|
30
|
+
# ========== Memory Scanner ==========
|
|
31
|
+
PROCESS_VM_READ = 0x0010
|
|
32
|
+
PROCESS_QUERY_INFORMATION = 0x0400
|
|
33
|
+
MEM_COMMIT = 0x1000
|
|
34
|
+
PAGE_NOACCESS = 0x01
|
|
35
|
+
PAGE_GUARD = 0x100
|
|
36
|
+
|
|
37
|
+
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
|
38
|
+
_fields_ = [
|
|
39
|
+
('BaseAddress', ctypes.c_void_p),
|
|
40
|
+
('AllocationBase', ctypes.c_void_p),
|
|
41
|
+
('AllocationProtect', wintypes.DWORD),
|
|
42
|
+
('PartitionId', wintypes.WORD),
|
|
43
|
+
('RegionSize', ctypes.c_size_t),
|
|
44
|
+
('State', wintypes.DWORD),
|
|
45
|
+
('Protect', wintypes.DWORD),
|
|
46
|
+
('Type', wintypes.DWORD),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
IS_WINDOWS = os.name == 'nt'
|
|
50
|
+
|
|
51
|
+
if IS_WINDOWS:
|
|
52
|
+
kernel32 = ctypes.windll.kernel32
|
|
53
|
+
ReadProcessMemory = kernel32.ReadProcessMemory
|
|
54
|
+
ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(c_size_t)]
|
|
55
|
+
ReadProcessMemory.restype = wintypes.BOOL
|
|
56
|
+
VirtualQueryEx = kernel32.VirtualQueryEx
|
|
57
|
+
VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
|
58
|
+
VirtualQueryEx.restype = ctypes.c_size_t
|
|
59
|
+
else:
|
|
60
|
+
kernel32 = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_weixin_pid():
|
|
64
|
+
"""Find WeChat process ID (Windows: pymem; Linux: /proc scan)."""
|
|
65
|
+
if not IS_WINDOWS:
|
|
66
|
+
return find_weixin_pid_linux()
|
|
67
|
+
try:
|
|
68
|
+
import pymem, pymem.process
|
|
69
|
+
for proc in pymem.process.list_processes():
|
|
70
|
+
try:
|
|
71
|
+
name = proc.szExeFile
|
|
72
|
+
if isinstance(name, bytes):
|
|
73
|
+
name = name.decode('utf-8', errors='ignore')
|
|
74
|
+
if name.lower() == 'weixin.exe':
|
|
75
|
+
return proc.th32ProcessID
|
|
76
|
+
except:
|
|
77
|
+
pass
|
|
78
|
+
except ImportError:
|
|
79
|
+
pass
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_LINUX_WECHAT_COMMS = {'wechat', 'wechatappex', 'weixin'}
|
|
84
|
+
_LINUX_EXE_PREFIX_DENY = ('python', 'bash', 'sh', 'zsh', 'node', 'perl', 'ruby', 'electron')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_linux_wechat_process(pid):
|
|
88
|
+
if pid == os.getpid():
|
|
89
|
+
return False
|
|
90
|
+
try:
|
|
91
|
+
with open(f'/proc/{pid}/comm') as f:
|
|
92
|
+
comm = f.read().strip().lower()
|
|
93
|
+
if comm in _LINUX_WECHAT_COMMS:
|
|
94
|
+
return True
|
|
95
|
+
try:
|
|
96
|
+
exe = os.path.realpath(os.readlink(f'/proc/{pid}/exe'))
|
|
97
|
+
except OSError:
|
|
98
|
+
return False
|
|
99
|
+
name = os.path.basename(exe).lower()
|
|
100
|
+
if any(name.startswith(p) for p in _LINUX_EXE_PREFIX_DENY):
|
|
101
|
+
return False
|
|
102
|
+
return 'wechat' in name or 'weixin' in name
|
|
103
|
+
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def find_weixin_pid_linux():
|
|
108
|
+
"""Find Linux WeChat main process (largest RSS among candidates)."""
|
|
109
|
+
best = None
|
|
110
|
+
best_rss = -1
|
|
111
|
+
try:
|
|
112
|
+
pids = os.listdir('/proc')
|
|
113
|
+
except OSError:
|
|
114
|
+
return None
|
|
115
|
+
for pid_str in pids:
|
|
116
|
+
if not pid_str.isdigit():
|
|
117
|
+
continue
|
|
118
|
+
pid = int(pid_str)
|
|
119
|
+
if not _is_linux_wechat_process(pid):
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
with open(f'/proc/{pid}/statm') as f:
|
|
123
|
+
rss_kb = int(f.read().split()[1]) * 4
|
|
124
|
+
except (OSError, IndexError, ValueError):
|
|
125
|
+
rss_kb = 0
|
|
126
|
+
if rss_kb > best_rss:
|
|
127
|
+
best_rss = rss_kb
|
|
128
|
+
best = pid
|
|
129
|
+
return best
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def scan_memory_keys(pid):
|
|
133
|
+
"""Scan process memory for x'<64hex_key><32hex_salt>' patterns.
|
|
134
|
+
|
|
135
|
+
Returns (keys, error): keys is a list of {"key","salt"} dicts,
|
|
136
|
+
error is None on success or 'permission' / 'gone' / 'not_windows'.
|
|
137
|
+
"""
|
|
138
|
+
if not IS_WINDOWS:
|
|
139
|
+
return scan_memory_keys_linux(pid)
|
|
140
|
+
|
|
141
|
+
hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
|
142
|
+
if not hProcess:
|
|
143
|
+
return [], None
|
|
144
|
+
|
|
145
|
+
pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
|
|
146
|
+
keys_found = []
|
|
147
|
+
address = 0x10000
|
|
148
|
+
|
|
149
|
+
while address < 0x7FFFFFFFFFFF:
|
|
150
|
+
mbi = MEMORY_BASIC_INFORMATION()
|
|
151
|
+
result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
|
|
152
|
+
if result == 0:
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
region_addr = mbi.BaseAddress or 0
|
|
156
|
+
region_size = mbi.RegionSize or 0
|
|
157
|
+
|
|
158
|
+
if (mbi.State == MEM_COMMIT and
|
|
159
|
+
region_size > 256 and region_size < 200 * 1024 * 1024 and
|
|
160
|
+
mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
|
|
161
|
+
|
|
162
|
+
pos = region_addr
|
|
163
|
+
end = region_addr + region_size
|
|
164
|
+
while pos < end:
|
|
165
|
+
chunk_size = min(65536, end - pos)
|
|
166
|
+
buf = create_string_buffer(chunk_size)
|
|
167
|
+
bytesRead = c_size_t(0)
|
|
168
|
+
ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
|
|
169
|
+
if ok and bytesRead.value > 0:
|
|
170
|
+
data = buf.raw[:bytesRead.value]
|
|
171
|
+
for m in pattern.finditer(data):
|
|
172
|
+
key_hex = m.group(1).decode()
|
|
173
|
+
salt_hex = m.group(2).decode()
|
|
174
|
+
keys_found.append((key_hex, salt_hex))
|
|
175
|
+
pos += chunk_size
|
|
176
|
+
|
|
177
|
+
address = region_addr + region_size
|
|
178
|
+
|
|
179
|
+
kernel32.CloseHandle(hProcess)
|
|
180
|
+
|
|
181
|
+
# Deduplicate
|
|
182
|
+
seen = set()
|
|
183
|
+
unique_keys = []
|
|
184
|
+
for k, s in keys_found:
|
|
185
|
+
pair = (k, s)
|
|
186
|
+
if pair not in seen:
|
|
187
|
+
seen.add(pair)
|
|
188
|
+
unique_keys.append({"key": k, "salt": s})
|
|
189
|
+
|
|
190
|
+
return unique_keys, None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_LINUX_SKIP_MAPPINGS = {'[vdso]', '[vsyscall]', '[vvar]'}
|
|
194
|
+
_LINUX_SKIP_PREFIXES = ('/usr/lib/', '/lib/', '/usr/share/')
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def scan_memory_keys_linux(pid):
|
|
198
|
+
"""Scan /proc/<pid>/maps + /proc/<pid>/mem for the key pattern.
|
|
199
|
+
|
|
200
|
+
Requires root or CAP_SYS_PTRACE (or the target being a descendant
|
|
201
|
+
of this process when yama ptrace_scope=1).
|
|
202
|
+
"""
|
|
203
|
+
regions = []
|
|
204
|
+
try:
|
|
205
|
+
with open(f'/proc/{pid}/maps') as f:
|
|
206
|
+
for line in f:
|
|
207
|
+
parts = line.split()
|
|
208
|
+
if len(parts) < 2 or 'r' not in parts[1]:
|
|
209
|
+
continue
|
|
210
|
+
if len(parts) >= 6:
|
|
211
|
+
name = parts[5]
|
|
212
|
+
if name in _LINUX_SKIP_MAPPINGS:
|
|
213
|
+
continue
|
|
214
|
+
name_lower = name.lower()
|
|
215
|
+
if name.startswith(_LINUX_SKIP_PREFIXES) and \
|
|
216
|
+
'wcdb' not in name_lower and 'wechat' not in name_lower and 'weixin' not in name_lower:
|
|
217
|
+
continue
|
|
218
|
+
try:
|
|
219
|
+
start_s, end_s = parts[0].split('-')
|
|
220
|
+
start = int(start_s, 16)
|
|
221
|
+
size = int(end_s, 16) - start
|
|
222
|
+
except ValueError:
|
|
223
|
+
continue
|
|
224
|
+
if 0 < size < 500 * 1024 * 1024:
|
|
225
|
+
regions.append((start, size))
|
|
226
|
+
except PermissionError:
|
|
227
|
+
return [], 'permission'
|
|
228
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
229
|
+
return [], 'gone'
|
|
230
|
+
|
|
231
|
+
pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
|
|
232
|
+
keys_found = []
|
|
233
|
+
try:
|
|
234
|
+
with open(f'/proc/{pid}/mem', 'rb') as mem:
|
|
235
|
+
for base, size in regions:
|
|
236
|
+
try:
|
|
237
|
+
mem.seek(base)
|
|
238
|
+
data = mem.read(size)
|
|
239
|
+
except (OSError, ValueError):
|
|
240
|
+
continue
|
|
241
|
+
for m in pattern.finditer(data):
|
|
242
|
+
keys_found.append((m.group(1).decode(), m.group(2).decode()))
|
|
243
|
+
except PermissionError:
|
|
244
|
+
return [], 'permission'
|
|
245
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
246
|
+
return [], 'gone'
|
|
247
|
+
|
|
248
|
+
seen = set()
|
|
249
|
+
unique_keys = []
|
|
250
|
+
for k, s in keys_found:
|
|
251
|
+
pair = (k, s)
|
|
252
|
+
if pair not in seen:
|
|
253
|
+
seen.add(pair)
|
|
254
|
+
unique_keys.append({"key": k, "salt": s})
|
|
255
|
+
|
|
256
|
+
return unique_keys, None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ========== NT Database Discovery ==========
|
|
260
|
+
|
|
261
|
+
def _is_nt_account_dir(path):
|
|
262
|
+
return os.path.isdir(os.path.join(path, 'db_storage')) or \
|
|
263
|
+
os.path.isdir(os.path.join(path, 'Msg'))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _normalize_nt_root(root):
|
|
267
|
+
if not root:
|
|
268
|
+
return None
|
|
269
|
+
path = os.path.abspath(os.path.expandvars(os.path.expanduser(root)))
|
|
270
|
+
if os.path.isfile(path):
|
|
271
|
+
path = os.path.dirname(path)
|
|
272
|
+
if not os.path.isdir(path):
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
for entry in os.listdir(path):
|
|
277
|
+
candidate = os.path.join(path, entry)
|
|
278
|
+
if os.path.isdir(candidate) and _is_nt_account_dir(candidate):
|
|
279
|
+
return path
|
|
280
|
+
except OSError:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
for _ in range(6):
|
|
284
|
+
if _is_nt_account_dir(path):
|
|
285
|
+
return os.path.dirname(path)
|
|
286
|
+
parent = os.path.dirname(path)
|
|
287
|
+
if parent == path:
|
|
288
|
+
break
|
|
289
|
+
path = parent
|
|
290
|
+
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def find_nt_databases(root=None):
|
|
295
|
+
"""Find all NT-format databases under xwechat_files (message + contact)."""
|
|
296
|
+
if root:
|
|
297
|
+
normalized_root = _normalize_nt_root(root)
|
|
298
|
+
candidates = [normalized_root] if normalized_root else []
|
|
299
|
+
elif IS_WINDOWS:
|
|
300
|
+
candidates = [
|
|
301
|
+
os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
|
|
302
|
+
os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
|
|
303
|
+
]
|
|
304
|
+
else:
|
|
305
|
+
home = os.path.expanduser('~')
|
|
306
|
+
candidates = [
|
|
307
|
+
os.path.join(home, '.local', 'share', 'xwechat_files'),
|
|
308
|
+
os.path.join(home, 'xwechat_files'),
|
|
309
|
+
os.path.join(home, 'Documents', 'xwechat_files'),
|
|
310
|
+
os.path.join(home, '文档', 'xwechat_files'),
|
|
311
|
+
]
|
|
312
|
+
xwechat = None
|
|
313
|
+
for c in candidates:
|
|
314
|
+
if os.path.isdir(c):
|
|
315
|
+
xwechat = c
|
|
316
|
+
break
|
|
317
|
+
if not xwechat:
|
|
318
|
+
return []
|
|
319
|
+
|
|
320
|
+
databases = []
|
|
321
|
+
for wxid_dir in os.listdir(xwechat):
|
|
322
|
+
# Scan message databases
|
|
323
|
+
msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
|
|
324
|
+
if os.path.isdir(msg_storage):
|
|
325
|
+
for f in os.listdir(msg_storage):
|
|
326
|
+
if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
|
|
327
|
+
full_path = os.path.join(msg_storage, f)
|
|
328
|
+
try:
|
|
329
|
+
with open(full_path, 'rb') as fh:
|
|
330
|
+
salt = fh.read(16)
|
|
331
|
+
databases.append({
|
|
332
|
+
"path": full_path,
|
|
333
|
+
"name": f"message/{f}",
|
|
334
|
+
"salt": salt.hex(),
|
|
335
|
+
"size": os.path.getsize(full_path),
|
|
336
|
+
"wxid": wxid_dir,
|
|
337
|
+
})
|
|
338
|
+
except:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
# Scan contact database
|
|
342
|
+
contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
343
|
+
if os.path.isfile(contact_db):
|
|
344
|
+
try:
|
|
345
|
+
with open(contact_db, 'rb') as fh:
|
|
346
|
+
salt = fh.read(16)
|
|
347
|
+
databases.append({
|
|
348
|
+
"path": contact_db,
|
|
349
|
+
"name": "contact/contact.db",
|
|
350
|
+
"salt": salt.hex(),
|
|
351
|
+
"size": os.path.getsize(contact_db),
|
|
352
|
+
"wxid": wxid_dir,
|
|
353
|
+
})
|
|
354
|
+
except:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
# Scan SNS (朋友圈) database
|
|
358
|
+
sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
|
|
359
|
+
if os.path.isfile(sns_db):
|
|
360
|
+
try:
|
|
361
|
+
with open(sns_db, 'rb') as fh:
|
|
362
|
+
salt = fh.read(16)
|
|
363
|
+
databases.append({
|
|
364
|
+
"path": sns_db,
|
|
365
|
+
"name": "sns/sns.db",
|
|
366
|
+
"salt": salt.hex(),
|
|
367
|
+
"size": os.path.getsize(sns_db),
|
|
368
|
+
"wxid": wxid_dir,
|
|
369
|
+
})
|
|
370
|
+
except:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# Scan favorites (收藏) database
|
|
374
|
+
fav_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'favorite', 'favorite.db')
|
|
375
|
+
if os.path.isfile(fav_db):
|
|
376
|
+
try:
|
|
377
|
+
with open(fav_db, 'rb') as fh:
|
|
378
|
+
salt = fh.read(16)
|
|
379
|
+
databases.append({
|
|
380
|
+
"path": fav_db,
|
|
381
|
+
"name": "favorite/favorite.db",
|
|
382
|
+
"salt": salt.hex(),
|
|
383
|
+
"size": os.path.getsize(fav_db),
|
|
384
|
+
"wxid": wxid_dir,
|
|
385
|
+
})
|
|
386
|
+
except:
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
return databases
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def find_contact_db_path(message_db_path):
|
|
393
|
+
"""Derive contact.db path from message_0.db path.
|
|
394
|
+
|
|
395
|
+
message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
|
|
396
|
+
contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
|
|
397
|
+
"""
|
|
398
|
+
msg_dir = os.path.dirname(message_db_path)
|
|
399
|
+
wxid_dir = os.path.dirname(msg_dir) # .../db_storage
|
|
400
|
+
xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
|
|
401
|
+
contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
|
|
402
|
+
if os.path.isfile(contact_db):
|
|
403
|
+
return contact_db
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
|
|
408
|
+
"""Load wxid -> {remark, nick_name} map from contact.db.
|
|
409
|
+
|
|
410
|
+
Returns dict: {wxid: display_name}
|
|
411
|
+
display_name priority: remark > nick_name > alias > wxid
|
|
412
|
+
"""
|
|
413
|
+
if not contact_db_path or not contact_key_hex or not contact_salt_hex:
|
|
414
|
+
return {}
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
|
|
418
|
+
conn = require_sqlcipher().connect(contact_db_path)
|
|
419
|
+
c = conn.cursor()
|
|
420
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
421
|
+
|
|
422
|
+
# contact.db schema: username, alias, remark, nick_name, ...
|
|
423
|
+
c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
|
|
424
|
+
name_map = {}
|
|
425
|
+
for username, display in c.fetchall():
|
|
426
|
+
if username:
|
|
427
|
+
name_map[username] = display
|
|
428
|
+
|
|
429
|
+
conn.close()
|
|
430
|
+
return name_map
|
|
431
|
+
except Exception as e:
|
|
432
|
+
return {}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def apply_contact_names(sessions, name_map):
|
|
436
|
+
"""Apply contact names to session list, replacing bare wxid displayNames."""
|
|
437
|
+
if not name_map:
|
|
438
|
+
return sessions
|
|
439
|
+
for s in sessions:
|
|
440
|
+
username = s.get('username', '')
|
|
441
|
+
if username in name_map:
|
|
442
|
+
s['displayName'] = name_map[username]
|
|
443
|
+
return sessions
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def match_keys_to_databases(keys, databases):
|
|
447
|
+
"""Match memory keys to databases by comparing salts."""
|
|
448
|
+
salt_to_key = {}
|
|
449
|
+
for k in keys:
|
|
450
|
+
salt_to_key[k["salt"]] = k["key"]
|
|
451
|
+
|
|
452
|
+
matched = []
|
|
453
|
+
for db in databases:
|
|
454
|
+
if db["salt"] in salt_to_key:
|
|
455
|
+
db["key"] = salt_to_key[db["salt"]]
|
|
456
|
+
matched.append(db)
|
|
457
|
+
|
|
458
|
+
return matched
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ========== Database Operations ==========
|
|
462
|
+
|
|
463
|
+
def connect_nt_db(db_path, key_hex, salt_hex):
|
|
464
|
+
"""Connect to an NT database using sqlcipher3."""
|
|
465
|
+
raw_key = f"x'{key_hex}{salt_hex}'"
|
|
466
|
+
conn = require_sqlcipher().connect(db_path)
|
|
467
|
+
c = conn.cursor()
|
|
468
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
469
|
+
return conn, c
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# WeChat rolls a conversation into a new shard over time, and every shard is
|
|
473
|
+
# encrypted with its own key. Reading only the configured database therefore
|
|
474
|
+
# shows a transcript that stops wherever the first shard's last write left off
|
|
475
|
+
# - which is silent, because the query still succeeds. Only the HTML exporter
|
|
476
|
+
# used to merge shards, so the same chat exported two different histories
|
|
477
|
+
# depending on the format asked for.
|
|
478
|
+
|
|
479
|
+
SHARD_EXCLUDED = {'message_fts.db', 'message_resource.db'}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def discover_message_shards(db_path):
|
|
483
|
+
"""Every NT message shard sitting beside the configured database."""
|
|
484
|
+
path = Path(db_path)
|
|
485
|
+
if not path.parent.is_dir():
|
|
486
|
+
return [str(path)]
|
|
487
|
+
shards = sorted(str(p) for p in path.parent.glob('message_*.db')
|
|
488
|
+
if p.name.lower() not in SHARD_EXCLUDED)
|
|
489
|
+
return shards or [str(path)]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
|
|
493
|
+
"""Per-shard SQLCipher key (WeChat 4.1.12.26+).
|
|
494
|
+
|
|
495
|
+
The shared passphrase is PBKDF2-HMAC-SHA512'd against each shard's own
|
|
496
|
+
16-byte header salt. Without a passphrase the configured pair is used
|
|
497
|
+
unchanged, which is what shard 0 opens with on older installs.
|
|
498
|
+
"""
|
|
499
|
+
if not passphrase:
|
|
500
|
+
return fallback_key, fallback_salt
|
|
501
|
+
try:
|
|
502
|
+
with open(path, 'rb') as fh:
|
|
503
|
+
salt = fh.read(16)
|
|
504
|
+
if len(salt) != 16:
|
|
505
|
+
return fallback_key, fallback_salt
|
|
506
|
+
raw_passphrase = bytes.fromhex(passphrase)
|
|
507
|
+
key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32).hex()
|
|
508
|
+
return key, salt.hex()
|
|
509
|
+
except (OSError, ValueError):
|
|
510
|
+
return fallback_key, fallback_salt
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
|
|
514
|
+
"""Open the configured database plus every sibling shard.
|
|
515
|
+
|
|
516
|
+
Returns the connections that actually opened. A shard that fails is
|
|
517
|
+
skipped rather than fatal: a partially readable transcript beats a
|
|
518
|
+
command that refuses to run at all.
|
|
519
|
+
"""
|
|
520
|
+
opened = []
|
|
521
|
+
for shard in discover_message_shards(db_path):
|
|
522
|
+
derived_key, derived_salt = derive_database_key(
|
|
523
|
+
shard, key_hex, salt_hex, passphrase)
|
|
524
|
+
# Try the derived key first; fall back to the configured pair so
|
|
525
|
+
# installs without a passphrase keep working exactly as before.
|
|
526
|
+
candidates = [(derived_key, derived_salt)]
|
|
527
|
+
if (derived_key, derived_salt) != (key_hex, salt_hex):
|
|
528
|
+
candidates.append((key_hex, salt_hex))
|
|
529
|
+
for candidate_key, candidate_salt in candidates:
|
|
530
|
+
try:
|
|
531
|
+
conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
|
|
532
|
+
# PRAGMA key alone never fails; only a read surfaces a bad key.
|
|
533
|
+
conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
|
|
534
|
+
except Exception:
|
|
535
|
+
continue
|
|
536
|
+
opened.append(conn)
|
|
537
|
+
break
|
|
538
|
+
return opened
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def msg_tables(conn):
|
|
542
|
+
"""Names of the per-conversation Msg_ tables in one shard."""
|
|
543
|
+
try:
|
|
544
|
+
rows = conn.execute(
|
|
545
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\'"
|
|
546
|
+
).fetchall()
|
|
547
|
+
except Exception:
|
|
548
|
+
return set()
|
|
549
|
+
return {row[0] for row in rows}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def get_fav_schema(db_path, key_hex):
|
|
553
|
+
"""Dump favorite.db schema + sample rows (key verification / exploration)."""
|
|
554
|
+
try:
|
|
555
|
+
with open(db_path, 'rb') as fh:
|
|
556
|
+
salt = fh.read(16).hex()
|
|
557
|
+
conn = require_sqlcipher().connect(db_path)
|
|
558
|
+
conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
|
|
559
|
+
tables = conn.execute(
|
|
560
|
+
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
561
|
+
).fetchall()
|
|
562
|
+
result = {"success": True, "tables": [t[0] for t in tables]}
|
|
563
|
+
if not tables:
|
|
564
|
+
result["error"] = "数据库为空或密钥错误"
|
|
565
|
+
result["success"] = False
|
|
566
|
+
conn.close()
|
|
567
|
+
return result
|
|
568
|
+
except Exception as e:
|
|
569
|
+
return {"success": False, "error": str(e).split('\n')[0][:200]}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
FAV_TYPE_NAMES = {
|
|
573
|
+
1: 'text', # 文字
|
|
574
|
+
2: 'image', # 图片
|
|
575
|
+
4: 'video', # 视频
|
|
576
|
+
5: 'article', # 公众号文章/网页链接
|
|
577
|
+
14: 'chatrecord', # 聊天记录
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def parse_fav_content(content):
|
|
582
|
+
"""Extract display fields (title/link/desc/source/cover) from favitem XML."""
|
|
583
|
+
out = {}
|
|
584
|
+
if not content:
|
|
585
|
+
return out
|
|
586
|
+
import xml.etree.ElementTree as ET
|
|
587
|
+
try:
|
|
588
|
+
root = ET.fromstring(content)
|
|
589
|
+
except ET.ParseError:
|
|
590
|
+
return out
|
|
591
|
+
|
|
592
|
+
def text(path):
|
|
593
|
+
el = root.find(path)
|
|
594
|
+
if el is not None and el.text and el.text.strip():
|
|
595
|
+
return el.text.strip()
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
title = (text('weburlitem/pagetitle')
|
|
599
|
+
or text('datalist/dataitem/datatitle')
|
|
600
|
+
or text('title')
|
|
601
|
+
or text('desc'))
|
|
602
|
+
if title:
|
|
603
|
+
out['title'] = title
|
|
604
|
+
link = (text('weburlitem/clean_url')
|
|
605
|
+
or text('source/link')
|
|
606
|
+
or text('datalist/dataitem/stream_weburl'))
|
|
607
|
+
if link:
|
|
608
|
+
out['link'] = link
|
|
609
|
+
desc = text('weburlitem/pagedesc') or text('datalist/dataitem/datadesc')
|
|
610
|
+
if desc and desc != out.get('title'):
|
|
611
|
+
out['desc'] = desc
|
|
612
|
+
src = text('weburlitem/appmsgshareitem/srcdisplayname')
|
|
613
|
+
if src:
|
|
614
|
+
out['source_name'] = src
|
|
615
|
+
cover = (text('weburlitem/pagethumb_url')
|
|
616
|
+
or text('datalist/dataitem/dataext')
|
|
617
|
+
or text('datalist/dataitem/cdn_thumburl'))
|
|
618
|
+
if cover:
|
|
619
|
+
out['cover'] = cover
|
|
620
|
+
fmt = text('datalist/dataitem/datafmt')
|
|
621
|
+
if fmt:
|
|
622
|
+
out['format'] = fmt
|
|
623
|
+
return out
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=None):
|
|
627
|
+
"""List favorite items from favorite.db with parsed content."""
|
|
628
|
+
try:
|
|
629
|
+
with open(db_path, 'rb') as fh:
|
|
630
|
+
salt = fh.read(16).hex()
|
|
631
|
+
conn = require_sqlcipher().connect(db_path)
|
|
632
|
+
conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
|
|
633
|
+
c = conn.cursor()
|
|
634
|
+
|
|
635
|
+
tables = [t[0] for t in c.execute(
|
|
636
|
+
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
|
|
637
|
+
if 'fav_db_item' not in tables:
|
|
638
|
+
conn.close()
|
|
639
|
+
return {"error": f"未找到 fav_db_item 表,现有表: {tables[:20]}"}
|
|
640
|
+
|
|
641
|
+
total = c.execute('SELECT count(*) FROM fav_db_item').fetchone()[0]
|
|
642
|
+
|
|
643
|
+
sql = ('SELECT local_id, server_id, type, update_time, fromusr, realchatname, content '
|
|
644
|
+
'FROM fav_db_item WHERE 1=1')
|
|
645
|
+
params = []
|
|
646
|
+
if fav_type is not None:
|
|
647
|
+
sql += ' AND type = ?'
|
|
648
|
+
params.append(fav_type)
|
|
649
|
+
if keyword:
|
|
650
|
+
sql += ' AND content LIKE ?'
|
|
651
|
+
params.append(f'%{keyword}%')
|
|
652
|
+
sql += ' ORDER BY update_time DESC LIMIT ? OFFSET ?'
|
|
653
|
+
params.extend([limit, offset])
|
|
654
|
+
rows = c.execute(sql, params).fetchall()
|
|
655
|
+
conn.close()
|
|
656
|
+
|
|
657
|
+
items = []
|
|
658
|
+
for local_id, server_id, ftype, update_time, fromusr, realchatname, content in rows:
|
|
659
|
+
ct = content if isinstance(content, str) else (
|
|
660
|
+
content.decode('utf-8', errors='replace') if content else '')
|
|
661
|
+
item = {
|
|
662
|
+
'local_id': local_id,
|
|
663
|
+
'server_id': server_id,
|
|
664
|
+
'type': ftype,
|
|
665
|
+
'type_name': FAV_TYPE_NAMES.get(ftype, 'type_%s' % ftype),
|
|
666
|
+
'update_time': update_time,
|
|
667
|
+
'from_user': fromusr,
|
|
668
|
+
'chat_name': realchatname or None,
|
|
669
|
+
}
|
|
670
|
+
item.update(parse_fav_content(ct))
|
|
671
|
+
items.append(item)
|
|
672
|
+
return {"favorites": items, "total": total, "count": len(items),
|
|
673
|
+
"limit": limit, "offset": offset}
|
|
674
|
+
except Exception as e:
|
|
675
|
+
return {"error": str(e).split('\n')[0][:200]}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _summarise(row):
|
|
679
|
+
"""(last_time, summary) from the newest message row of a conversation."""
|
|
680
|
+
last_time = row[0] or 0
|
|
681
|
+
msg_type = row[3] or 0
|
|
682
|
+
source_text = row[1]
|
|
683
|
+
content_text = row[2]
|
|
684
|
+
|
|
685
|
+
if msg_type == 1:
|
|
686
|
+
# Text message: use message_content
|
|
687
|
+
if isinstance(content_text, str) and content_text:
|
|
688
|
+
return last_time, content_text[:50]
|
|
689
|
+
if isinstance(content_text, bytes):
|
|
690
|
+
return last_time, content_text.decode('utf-8', errors='ignore')[:50]
|
|
691
|
+
return last_time, ""
|
|
692
|
+
|
|
693
|
+
if isinstance(source_text, str) and source_text:
|
|
694
|
+
# Non-text: try to extract from source, stripping XML tags
|
|
695
|
+
clean = re.sub(r'<[^>]+>', '', source_text)
|
|
696
|
+
lines = clean.split('\n')
|
|
697
|
+
if len(lines) > 1 and lines[1].strip():
|
|
698
|
+
return last_time, lines[1].strip()[:50]
|
|
699
|
+
if clean.strip():
|
|
700
|
+
return last_time, clean.strip()[:50]
|
|
701
|
+
return last_time, ""
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def get_sessions(conns):
|
|
705
|
+
"""Get chat sessions, newest message per conversation across every shard."""
|
|
706
|
+
sessions = {}
|
|
707
|
+
|
|
708
|
+
# NT format: each chat has its own Msg_<MD5> table
|
|
709
|
+
# The Name2Id table maps usernames to IDs (user_name, is_session)
|
|
710
|
+
for conn in conns:
|
|
711
|
+
c = conn.cursor()
|
|
712
|
+
try:
|
|
713
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
|
|
714
|
+
usernames = [row[0] for row in c.fetchall() if row[0]]
|
|
715
|
+
except Exception:
|
|
716
|
+
continue
|
|
717
|
+
|
|
718
|
+
tables = msg_tables(conn)
|
|
719
|
+
for username in usernames:
|
|
720
|
+
entry = sessions.setdefault(username, {"summary": "", "last_time": 0})
|
|
721
|
+
msg_table = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
|
|
722
|
+
if msg_table not in tables:
|
|
723
|
+
continue
|
|
724
|
+
try:
|
|
725
|
+
c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
|
|
726
|
+
row = c.fetchone()
|
|
727
|
+
if row:
|
|
728
|
+
last_time, summary = _summarise(row)
|
|
729
|
+
# A conversation spans shards; only the newest speaks for it.
|
|
730
|
+
if last_time >= entry["last_time"]:
|
|
731
|
+
entry["last_time"] = last_time
|
|
732
|
+
entry["summary"] = summary
|
|
733
|
+
except Exception:
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
result = [{
|
|
737
|
+
"username": username,
|
|
738
|
+
"type": 1 if "@chatroom" in username else 0,
|
|
739
|
+
"unreadCount": 0,
|
|
740
|
+
"summary": entry["summary"],
|
|
741
|
+
"sortTimestamp": entry["last_time"],
|
|
742
|
+
"lastTimestamp": entry["last_time"],
|
|
743
|
+
"displayName": username,
|
|
744
|
+
} for username, entry in sessions.items()]
|
|
745
|
+
|
|
746
|
+
# Sort by timestamp descending
|
|
747
|
+
result.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
|
|
748
|
+
return {"sessions": result}
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _strip_group_speaker(content, known_ids):
|
|
752
|
+
"""Drop the `wxid_...:` prefix group rows carry in their content.
|
|
753
|
+
|
|
754
|
+
Only an id the shard's Name2Id actually knows is accepted, so a message
|
|
755
|
+
that merely starts with `note: ...` is left alone.
|
|
756
|
+
"""
|
|
757
|
+
match = re.match(r'^([A-Za-z0-9_@.-]{5,64})\s*[::]\s', str(content or ''))
|
|
758
|
+
if not match or match.group(1) not in known_ids:
|
|
759
|
+
return content
|
|
760
|
+
return content[match.end():]
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
|
|
764
|
+
"""One message row -> the CLI's message shape."""
|
|
765
|
+
local_type = row[2] or 0
|
|
766
|
+
create_time = row[5] or 0
|
|
767
|
+
real_sender_id = row[4] or 0
|
|
768
|
+
|
|
769
|
+
# Resolve sender: real_sender_id -> Name2Id -> user_name
|
|
770
|
+
sender_username = sender_id_map.get(real_sender_id, "")
|
|
771
|
+
|
|
772
|
+
# Determine if message is from self
|
|
773
|
+
# own_wxid may have _xxxx suffix (from xwechat_files dir), try both
|
|
774
|
+
is_self = bool(own_wxid and (
|
|
775
|
+
sender_username == own_wxid or
|
|
776
|
+
(own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
|
|
777
|
+
))
|
|
778
|
+
if not is_self and own_wxid:
|
|
779
|
+
# Strip _xxxx suffix and retry
|
|
780
|
+
parts = own_wxid.rsplit('_', 1)
|
|
781
|
+
if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
|
|
782
|
+
is_self = (sender_username == parts[0])
|
|
783
|
+
|
|
784
|
+
# Resolve sender display name from contact map
|
|
785
|
+
if is_self:
|
|
786
|
+
sender_display = "" # Let the CLI show "我"
|
|
787
|
+
else:
|
|
788
|
+
sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
|
|
789
|
+
|
|
790
|
+
# Parse message_content - TEXT column
|
|
791
|
+
content = row[12] if isinstance(row[12], str) else ""
|
|
792
|
+
|
|
793
|
+
# `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
|
|
794
|
+
# field every consumer reads first - gets the display-ready form.
|
|
795
|
+
display = _strip_group_speaker(content, set(sender_id_map.values())) if is_group else content
|
|
796
|
+
|
|
797
|
+
return {
|
|
798
|
+
"localId": row[0] or 0,
|
|
799
|
+
"serverId": str(row[1] or ''),
|
|
800
|
+
"localType": local_type,
|
|
801
|
+
"createTime": create_time,
|
|
802
|
+
"isSend": 1 if is_self else 0, # 1 = I sent this
|
|
803
|
+
"senderUsername": sender_username,
|
|
804
|
+
"senderDisplay": sender_display,
|
|
805
|
+
"content": content,
|
|
806
|
+
"rawContent": content,
|
|
807
|
+
"parsedContent": display[:200] if local_type == 1 else "",
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None):
|
|
812
|
+
"""Get messages for a specific talker, merged across every shard.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
name_map: optional {wxid: display_name} dict for resolving sender names
|
|
816
|
+
own_wxid: account owner wxid for self-message detection
|
|
817
|
+
"""
|
|
818
|
+
if name_map is None:
|
|
819
|
+
name_map = {}
|
|
820
|
+
|
|
821
|
+
msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
822
|
+
is_group = '@chatroom' in talker
|
|
823
|
+
|
|
824
|
+
# Each shard only needs to yield its newest window: once every shard's rows
|
|
825
|
+
# are merged and re-sorted, nothing older than that can reach this page.
|
|
826
|
+
window = 0 if limit <= 0 else limit + offset
|
|
827
|
+
collected = []
|
|
828
|
+
found = False
|
|
829
|
+
|
|
830
|
+
for conn in conns:
|
|
831
|
+
c = conn.cursor()
|
|
832
|
+
try:
|
|
833
|
+
c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
|
|
834
|
+
if c.fetchone()[0] == 0:
|
|
835
|
+
continue
|
|
836
|
+
found = True
|
|
837
|
+
|
|
838
|
+
sql = f'''
|
|
839
|
+
SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
|
|
840
|
+
create_time, status, upload_status, download_status,
|
|
841
|
+
server_seq, origin_source, source, message_content, compress_content
|
|
842
|
+
FROM "{msg_table}"
|
|
843
|
+
ORDER BY create_time DESC, local_id DESC
|
|
844
|
+
'''
|
|
845
|
+
if window:
|
|
846
|
+
c.execute(sql + ' LIMIT ?', (window,))
|
|
847
|
+
else:
|
|
848
|
+
c.execute(sql)
|
|
849
|
+
rows = c.fetchall()
|
|
850
|
+
|
|
851
|
+
# Sender ids are rowids, so the map has to come from the same shard.
|
|
852
|
+
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
853
|
+
sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
|
|
854
|
+
except Exception:
|
|
855
|
+
continue
|
|
856
|
+
|
|
857
|
+
for row in rows:
|
|
858
|
+
collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
|
|
859
|
+
|
|
860
|
+
if not found:
|
|
861
|
+
return {"error": f"未找到会话: {talker}"}
|
|
862
|
+
|
|
863
|
+
collected.sort(key=lambda m: (m["createTime"], m["localId"]), reverse=True)
|
|
864
|
+
if limit > 0:
|
|
865
|
+
collected = collected[offset:offset + limit]
|
|
866
|
+
return {"messages": collected}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def get_contacts(conns, limit=200):
|
|
870
|
+
"""Get contacts from NT database, merged across every shard."""
|
|
871
|
+
seen = set()
|
|
872
|
+
contacts = []
|
|
873
|
+
for conn in conns:
|
|
874
|
+
try:
|
|
875
|
+
rows = conn.execute("SELECT user_name FROM Name2Id LIMIT ?", (limit,)).fetchall()
|
|
876
|
+
except Exception:
|
|
877
|
+
continue
|
|
878
|
+
for (username,) in rows:
|
|
879
|
+
# Name2Id carries a placeholder row with no user_name; rendering it
|
|
880
|
+
# produced a blank line at the top of every contact list.
|
|
881
|
+
if not username or username in seen:
|
|
882
|
+
continue
|
|
883
|
+
seen.add(username)
|
|
884
|
+
contacts.append({"username": username, "displayName": username})
|
|
885
|
+
return {"contacts": contacts}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
# ========== SNS (朋友圈) Queries ==========
|
|
889
|
+
|
|
890
|
+
def parse_sns_content(content_str):
|
|
891
|
+
"""Parse SNS content XML/Protobuf text to extract title, description, media etc."""
|
|
892
|
+
result = {
|
|
893
|
+
'content': '',
|
|
894
|
+
'create_time': 0,
|
|
895
|
+
'username': '',
|
|
896
|
+
'object_id': '',
|
|
897
|
+
'media_count': 0,
|
|
898
|
+
}
|
|
899
|
+
if not content_str:
|
|
900
|
+
return result
|
|
901
|
+
|
|
902
|
+
import re
|
|
903
|
+
|
|
904
|
+
# Extract createTime
|
|
905
|
+
m = re.search(r'<createTime>(\d+)</createTime>', content_str)
|
|
906
|
+
if m:
|
|
907
|
+
result['create_time'] = int(m.group(1))
|
|
908
|
+
|
|
909
|
+
# Extract username
|
|
910
|
+
m = re.search(r'<username>([^<]+)</username>', content_str)
|
|
911
|
+
if m:
|
|
912
|
+
result['username'] = m.group(1)
|
|
913
|
+
|
|
914
|
+
# Extract id
|
|
915
|
+
m = re.search(r'<id>(\d+)</id>', content_str)
|
|
916
|
+
if m:
|
|
917
|
+
result['object_id'] = m.group(1)
|
|
918
|
+
|
|
919
|
+
# Extract contentDesc (main text)
|
|
920
|
+
m = re.search(r'<contentDesc>([^<]*)</contentDesc>', content_str)
|
|
921
|
+
if m:
|
|
922
|
+
result['content'] = m.group(1)
|
|
923
|
+
|
|
924
|
+
# Extract contentDesc CDATA
|
|
925
|
+
m = re.search(r'<contentDesc>\s*<!\[CDATA\[(.*?)\]\]>\s*</contentDesc>', content_str, re.DOTALL)
|
|
926
|
+
if m:
|
|
927
|
+
result['content'] = m.group(1).strip()
|
|
928
|
+
|
|
929
|
+
# Extract title if present
|
|
930
|
+
m = re.search(r'<title>([^<]*)</title>', content_str)
|
|
931
|
+
if m:
|
|
932
|
+
title = m.group(1)
|
|
933
|
+
if title and not result['content']:
|
|
934
|
+
result['content'] = title
|
|
935
|
+
elif title:
|
|
936
|
+
result['content'] = title + '\n' + result['content']
|
|
937
|
+
|
|
938
|
+
# Count media (ContentObject tags)
|
|
939
|
+
result['media_count'] = len(re.findall(r'<ContentObject[ >]', content_str))
|
|
940
|
+
|
|
941
|
+
return result
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def get_sns_timeline(cursor, limit=20, offset=0, usernames=None, keyword=None,
|
|
945
|
+
start_time=None, end_time=None):
|
|
946
|
+
"""Query SNS timeline posts from SnsTimeLine table."""
|
|
947
|
+
# Check if SnsTimeLine exists, fall back to SnsTopItem_1
|
|
948
|
+
tables = [r[0] for r in cursor.execute(
|
|
949
|
+
"SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
|
|
950
|
+
|
|
951
|
+
table = 'SnsTimeLine' if 'SnsTimeLine' in tables else None
|
|
952
|
+
if not table:
|
|
953
|
+
table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else None
|
|
954
|
+
if not table:
|
|
955
|
+
return {'success': False, 'error': 'No SNS table found'}
|
|
956
|
+
|
|
957
|
+
conditions = []
|
|
958
|
+
params = []
|
|
959
|
+
|
|
960
|
+
if table == 'SnsTimeLine':
|
|
961
|
+
if usernames:
|
|
962
|
+
placeholders = ','.join(['?' for _ in usernames])
|
|
963
|
+
conditions.append(f'user_name IN ({placeholders})')
|
|
964
|
+
params.extend(usernames)
|
|
965
|
+
if keyword:
|
|
966
|
+
conditions.append('content LIKE ?')
|
|
967
|
+
params.append(f'%{keyword}%')
|
|
968
|
+
if start_time:
|
|
969
|
+
conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) >= ?")
|
|
970
|
+
params.append(start_time)
|
|
971
|
+
if end_time:
|
|
972
|
+
conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) <= ?")
|
|
973
|
+
params.append(end_time)
|
|
974
|
+
elif table == 'SnsTopItem_1':
|
|
975
|
+
uname_col = 'username' if 'username' in [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()] else 'user_name'
|
|
976
|
+
if usernames:
|
|
977
|
+
placeholders = ','.join(['?' for _ in usernames])
|
|
978
|
+
conditions.append(f'{uname_col} IN ({placeholders})')
|
|
979
|
+
params.extend(usernames)
|
|
980
|
+
if keyword:
|
|
981
|
+
conditions.append('summary LIKE ?')
|
|
982
|
+
params.append(f'%{keyword}%')
|
|
983
|
+
if start_time:
|
|
984
|
+
conditions.append('create_time >= ?')
|
|
985
|
+
params.append(start_time)
|
|
986
|
+
if end_time:
|
|
987
|
+
conditions.append('create_time <= ?')
|
|
988
|
+
params.append(end_time)
|
|
989
|
+
|
|
990
|
+
where = ' AND '.join(conditions) if conditions else '1=1'
|
|
991
|
+
order = 'tid DESC' if table == 'SnsTimeLine' else 'create_time DESC'
|
|
992
|
+
|
|
993
|
+
try:
|
|
994
|
+
cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
|
|
995
|
+
# Only select known text columns to avoid binary decode errors
|
|
996
|
+
text_cols = [c for c in cols if c in ('tid', 'user_name', 'content', 'username', 'summary',
|
|
997
|
+
'create_time', 'last_read_time', 'is_read',
|
|
998
|
+
'from_username', 'from_nickname', 'to_username',
|
|
999
|
+
'to_nickname', 'comment_id', 'feed_id',
|
|
1000
|
+
'createTime', 'userName')]
|
|
1001
|
+
if not text_cols:
|
|
1002
|
+
text_cols = ['*']
|
|
1003
|
+
select_str = ', '.join(text_cols)
|
|
1004
|
+
rows = cursor.execute(
|
|
1005
|
+
f'SELECT {select_str} FROM [{table}] WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?',
|
|
1006
|
+
params + [limit, offset]
|
|
1007
|
+
).fetchall()
|
|
1008
|
+
|
|
1009
|
+
timeline = []
|
|
1010
|
+
|
|
1011
|
+
for row in rows:
|
|
1012
|
+
item = dict(zip(text_cols, row))
|
|
1013
|
+
|
|
1014
|
+
if table == 'SnsTimeLine':
|
|
1015
|
+
# Parse XML content
|
|
1016
|
+
content_str = item.get('content', '') or ''
|
|
1017
|
+
parsed = parse_sns_content(content_str)
|
|
1018
|
+
create_time = parsed['create_time'] or 0
|
|
1019
|
+
username = parsed['username'] or item.get('user_name', '')
|
|
1020
|
+
text = parsed['content'] or ''
|
|
1021
|
+
media_count = parsed['media_count']
|
|
1022
|
+
else:
|
|
1023
|
+
create_time = item.get('create_time', 0) or 0
|
|
1024
|
+
username = item.get('username', '') or item.get('user_name', '')
|
|
1025
|
+
text = item.get('summary', '') or ''
|
|
1026
|
+
media_count = 0
|
|
1027
|
+
|
|
1028
|
+
timeline.append({
|
|
1029
|
+
'create_time': create_time,
|
|
1030
|
+
'username': username,
|
|
1031
|
+
'content': text,
|
|
1032
|
+
'media_count': media_count,
|
|
1033
|
+
'table': table,
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
return {'success': True, 'timeline': timeline, 'table': table}
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
return {'success': False, 'error': str(e)}
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def get_sns_usernames(cursor):
|
|
1042
|
+
"""Get unique usernames from SnsTopItem_1."""
|
|
1043
|
+
tables = [r[0] for r in cursor.execute(
|
|
1044
|
+
"SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
|
|
1045
|
+
|
|
1046
|
+
# Prefer SnsTopItem_1 for user listing (larger)
|
|
1047
|
+
table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
|
|
1048
|
+
if not table:
|
|
1049
|
+
return {'success': False, 'error': 'No SNS table found'}
|
|
1050
|
+
|
|
1051
|
+
# Discover username column
|
|
1052
|
+
cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
|
|
1053
|
+
uname_col = 'username' if 'username' in cols else 'user_name'
|
|
1054
|
+
|
|
1055
|
+
try:
|
|
1056
|
+
rows = cursor.execute(
|
|
1057
|
+
f'SELECT [{uname_col}], COUNT(*) as cnt FROM [{table}] GROUP BY [{uname_col}] ORDER BY cnt DESC'
|
|
1058
|
+
).fetchall()
|
|
1059
|
+
usernames = [r[0] for r in rows if r[0]]
|
|
1060
|
+
counts = {r[0]: r[1] for r in rows if r[0]}
|
|
1061
|
+
return {'success': True, 'usernames': usernames, 'counts': counts}
|
|
1062
|
+
except Exception as e:
|
|
1063
|
+
return {'success': False, 'error': str(e)}
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def get_sns_stats(cursor, my_wxid=None):
|
|
1067
|
+
"""Get SNS statistics from SnsTopItem_1."""
|
|
1068
|
+
tables = [r[0] for r in cursor.execute(
|
|
1069
|
+
"SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
|
|
1070
|
+
|
|
1071
|
+
table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
|
|
1072
|
+
if not table:
|
|
1073
|
+
return {'success': False, 'error': 'No SNS table found'}
|
|
1074
|
+
|
|
1075
|
+
try:
|
|
1076
|
+
total = cursor.execute(f'SELECT COUNT(*) FROM [{table}]').fetchone()[0]
|
|
1077
|
+
|
|
1078
|
+
cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
|
|
1079
|
+
uname_col = 'username' if 'username' in cols else 'user_name'
|
|
1080
|
+
|
|
1081
|
+
total_friends = 0
|
|
1082
|
+
if uname_col:
|
|
1083
|
+
total_friends = cursor.execute(
|
|
1084
|
+
f'SELECT COUNT(DISTINCT [{uname_col}]) FROM [{table}] WHERE [{uname_col}] IS NOT NULL'
|
|
1085
|
+
).fetchone()[0]
|
|
1086
|
+
|
|
1087
|
+
my_posts = None
|
|
1088
|
+
if my_wxid and uname_col:
|
|
1089
|
+
my_posts = cursor.execute(
|
|
1090
|
+
f'SELECT COUNT(*) FROM [{table}] WHERE [{uname_col}] = ?',
|
|
1091
|
+
(my_wxid,)).fetchone()[0]
|
|
1092
|
+
|
|
1093
|
+
return {
|
|
1094
|
+
'success': True,
|
|
1095
|
+
'data': {
|
|
1096
|
+
'totalPosts': total,
|
|
1097
|
+
'totalFriends': total_friends,
|
|
1098
|
+
'myPosts': my_posts,
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
except Exception as e:
|
|
1102
|
+
return {'success': False, 'error': str(e)}
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# ========== Main CLI ==========
|
|
1106
|
+
|
|
1107
|
+
def main():
|
|
1108
|
+
import argparse
|
|
1109
|
+
parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
|
|
1110
|
+
sub = parser.add_subparsers(dest='command')
|
|
1111
|
+
|
|
1112
|
+
# scan command
|
|
1113
|
+
scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
|
|
1114
|
+
scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
|
1115
|
+
scan_parser.add_argument('--root', default=os.environ.get('WEFLOW_SCAN_ROOT'), help='xwechat_files root directory override')
|
|
1116
|
+
|
|
1117
|
+
# sessions command
|
|
1118
|
+
sessions_parser = sub.add_parser('sessions', help='List chat sessions')
|
|
1119
|
+
sessions_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1120
|
+
sessions_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1121
|
+
sessions_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1122
|
+
sessions_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
|
|
1123
|
+
sessions_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
|
|
1124
|
+
sessions_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
|
|
1125
|
+
sessions_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
|
|
1126
|
+
sessions_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
|
|
1127
|
+
|
|
1128
|
+
# messages command
|
|
1129
|
+
msg_parser = sub.add_parser('messages', help='Get messages')
|
|
1130
|
+
msg_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1131
|
+
msg_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1132
|
+
msg_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1133
|
+
msg_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
|
|
1134
|
+
msg_parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
|
|
1135
|
+
msg_parser.add_argument('--limit', type=int, default=100)
|
|
1136
|
+
msg_parser.add_argument('--offset', type=int, default=0)
|
|
1137
|
+
msg_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for sender names')
|
|
1138
|
+
msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
|
|
1139
|
+
msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
|
|
1140
|
+
msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
|
|
1141
|
+
|
|
1142
|
+
# contacts command
|
|
1143
|
+
contacts_parser = sub.add_parser('contacts', help='List contacts')
|
|
1144
|
+
contacts_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1145
|
+
contacts_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1146
|
+
contacts_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1147
|
+
contacts_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
|
|
1148
|
+
contacts_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
|
|
1149
|
+
contacts_parser.add_argument('--limit', type=int, default=200)
|
|
1150
|
+
contacts_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
|
|
1151
|
+
contacts_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
|
|
1152
|
+
contacts_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
|
|
1153
|
+
|
|
1154
|
+
# sns-timeline command
|
|
1155
|
+
sns_tl_parser = sub.add_parser('sns-timeline', help='Get SNS/Moments timeline')
|
|
1156
|
+
sns_tl_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
|
|
1157
|
+
sns_tl_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1158
|
+
sns_tl_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1159
|
+
sns_tl_parser.add_argument('--limit', type=int, default=20)
|
|
1160
|
+
sns_tl_parser.add_argument('--offset', type=int, default=0)
|
|
1161
|
+
sns_tl_parser.add_argument('--usernames', default=os.environ.get('WEFLOW_QUERY_USERNAMES'), help='JSON array of usernames to filter')
|
|
1162
|
+
sns_tl_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
|
|
1163
|
+
sns_tl_parser.add_argument('--start-time', type=int, help='Start timestamp')
|
|
1164
|
+
sns_tl_parser.add_argument('--end-time', type=int, help='End timestamp')
|
|
1165
|
+
|
|
1166
|
+
# sns-usernames command
|
|
1167
|
+
sns_un_parser = sub.add_parser('sns-usernames', help='List usernames with SNS posts')
|
|
1168
|
+
sns_un_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
|
|
1169
|
+
sns_un_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1170
|
+
sns_un_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1171
|
+
|
|
1172
|
+
# sns-stats command
|
|
1173
|
+
sns_stats_parser = sub.add_parser('sns-stats', help='SNS statistics')
|
|
1174
|
+
sns_stats_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
|
|
1175
|
+
sns_stats_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1176
|
+
sns_stats_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1177
|
+
sns_stats_parser.add_argument('--my-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid for my-posts count')
|
|
1178
|
+
|
|
1179
|
+
# fav-schema command
|
|
1180
|
+
fav_schema_parser = sub.add_parser('fav-schema', help='Dump favorite.db schema (key verification)')
|
|
1181
|
+
fav_schema_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
|
|
1182
|
+
fav_schema_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1183
|
+
|
|
1184
|
+
verify_parser = sub.add_parser('verify', help='Verify a key+salt can open a database')
|
|
1185
|
+
verify_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1186
|
+
verify_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1187
|
+
verify_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1188
|
+
|
|
1189
|
+
# fav-list command
|
|
1190
|
+
fav_list_parser = sub.add_parser('fav-list', help='List favorite items')
|
|
1191
|
+
fav_list_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
|
|
1192
|
+
fav_list_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1193
|
+
fav_list_parser.add_argument('--limit', type=int, default=100)
|
|
1194
|
+
fav_list_parser.add_argument('--offset', type=int, default=0)
|
|
1195
|
+
fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
|
|
1196
|
+
fav_list_parser.add_argument('--type', type=int, dest='fav_type',
|
|
1197
|
+
help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
|
|
1198
|
+
|
|
1199
|
+
args = parser.parse_args()
|
|
1200
|
+
|
|
1201
|
+
if args.command == 'scan':
|
|
1202
|
+
# Discover the database files before touching the process. Walking the
|
|
1203
|
+
# filesystem does not need WeChat running, but key matching does - and
|
|
1204
|
+
# the caller derives keys from the passphrase when the memory scan
|
|
1205
|
+
# finds nothing, which only needs the file list. Returning early on
|
|
1206
|
+
# "process not running" without the databases threw that list away and
|
|
1207
|
+
# left the caller with nothing to derive from.
|
|
1208
|
+
databases = find_nt_databases(getattr(args, 'root', None))
|
|
1209
|
+
|
|
1210
|
+
pid = find_weixin_pid()
|
|
1211
|
+
if not pid:
|
|
1212
|
+
if IS_WINDOWS:
|
|
1213
|
+
print(json.dumps({"error": "Weixin.exe 未运行", "databases": databases}))
|
|
1214
|
+
else:
|
|
1215
|
+
print(json.dumps({"error": "未检测到 Linux 微信进程,请确认微信已启动并登录",
|
|
1216
|
+
"databases": databases}))
|
|
1217
|
+
return
|
|
1218
|
+
|
|
1219
|
+
if not args.json:
|
|
1220
|
+
print(f"扫描进程 PID {pid}...")
|
|
1221
|
+
|
|
1222
|
+
keys, scan_err = scan_memory_keys(pid)
|
|
1223
|
+
if scan_err == 'permission':
|
|
1224
|
+
print(json.dumps({"error": "PERMISSION_DENIED: 读取微信进程内存需要 root 或 CAP_SYS_PTRACE 权限。可使用 sudo 运行,或执行: sudo setcap cap_sys_ptrace=ep $(which python3)",
|
|
1225
|
+
"databases": databases}))
|
|
1226
|
+
return
|
|
1227
|
+
if scan_err == 'gone':
|
|
1228
|
+
print(json.dumps({"error": "微信进程已退出,请重试", "databases": databases}))
|
|
1229
|
+
return
|
|
1230
|
+
if not args.json:
|
|
1231
|
+
print(f"找到 {len(keys)} 个密钥")
|
|
1232
|
+
|
|
1233
|
+
if not args.json:
|
|
1234
|
+
print(f"找到 {len(databases)} 个 NT 数据库")
|
|
1235
|
+
|
|
1236
|
+
matched = match_keys_to_databases(keys, databases)
|
|
1237
|
+
if not args.json:
|
|
1238
|
+
print(f"匹配 {len(matched)} 个数据库")
|
|
1239
|
+
for db in matched:
|
|
1240
|
+
print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
|
|
1241
|
+
else:
|
|
1242
|
+
print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
|
|
1243
|
+
return
|
|
1244
|
+
|
|
1245
|
+
if args.command == 'verify':
|
|
1246
|
+
# sqlcipher 打开+读 sqlite_master 才触发解密, 错误密钥在此报 "file is not a database"
|
|
1247
|
+
try:
|
|
1248
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1249
|
+
n = conn.execute(
|
|
1250
|
+
"SELECT count(*) FROM sqlite_master WHERE type='table'"
|
|
1251
|
+
).fetchone()[0]
|
|
1252
|
+
conn.close()
|
|
1253
|
+
print(json.dumps({"success": n > 0, "tables": n}, ensure_ascii=True))
|
|
1254
|
+
except Exception as e:
|
|
1255
|
+
print(json.dumps({"success": False, "error": str(e).split('\n')[0][:200]}, ensure_ascii=True))
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
# Build contact name map once if contact db provided
|
|
1259
|
+
contact_name_map = {}
|
|
1260
|
+
contact_db = getattr(args, 'contact_db', None)
|
|
1261
|
+
contact_key = getattr(args, 'contact_key', None)
|
|
1262
|
+
contact_salt = getattr(args, 'contact_salt', None)
|
|
1263
|
+
if contact_db and contact_key and contact_salt:
|
|
1264
|
+
contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
|
|
1265
|
+
|
|
1266
|
+
if args.command == 'sessions':
|
|
1267
|
+
conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
|
|
1268
|
+
if not conns:
|
|
1269
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1270
|
+
return
|
|
1271
|
+
result = get_sessions(conns)
|
|
1272
|
+
if 'sessions' in result:
|
|
1273
|
+
result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
|
|
1274
|
+
if args.keyword:
|
|
1275
|
+
kw = args.keyword.lower()
|
|
1276
|
+
result['sessions'] = [
|
|
1277
|
+
s for s in result['sessions']
|
|
1278
|
+
if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
|
|
1279
|
+
]
|
|
1280
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1281
|
+
for conn in conns:
|
|
1282
|
+
conn.close()
|
|
1283
|
+
|
|
1284
|
+
elif args.command == 'messages':
|
|
1285
|
+
conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
|
|
1286
|
+
if not conns:
|
|
1287
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1288
|
+
return
|
|
1289
|
+
own_wxid = getattr(args, 'own_wxid', None)
|
|
1290
|
+
result = get_messages(conns, args.talker, args.limit, args.offset, contact_name_map, own_wxid)
|
|
1291
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1292
|
+
for conn in conns:
|
|
1293
|
+
conn.close()
|
|
1294
|
+
|
|
1295
|
+
elif args.command == 'contacts':
|
|
1296
|
+
conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
|
|
1297
|
+
if not conns:
|
|
1298
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1299
|
+
return
|
|
1300
|
+
result = get_contacts(conns, args.limit)
|
|
1301
|
+
if 'contacts' in result:
|
|
1302
|
+
result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
|
|
1303
|
+
if args.keyword:
|
|
1304
|
+
kw = args.keyword.lower()
|
|
1305
|
+
result['contacts'] = [
|
|
1306
|
+
c for c in result['contacts']
|
|
1307
|
+
if kw in (c.get('username', '') + c.get('displayName', '') + c.get('remark', '') + c.get('nickname', '')).lower()
|
|
1308
|
+
]
|
|
1309
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1310
|
+
for conn in conns:
|
|
1311
|
+
conn.close()
|
|
1312
|
+
|
|
1313
|
+
elif args.command == 'sns-timeline':
|
|
1314
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1315
|
+
usernames = None
|
|
1316
|
+
if args.usernames:
|
|
1317
|
+
try:
|
|
1318
|
+
usernames = json.loads(args.usernames)
|
|
1319
|
+
except: pass
|
|
1320
|
+
result = get_sns_timeline(conn.cursor(), args.limit, args.offset,
|
|
1321
|
+
usernames, args.keyword,
|
|
1322
|
+
args.start_time, args.end_time)
|
|
1323
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1324
|
+
conn.close()
|
|
1325
|
+
|
|
1326
|
+
elif args.command == 'sns-usernames':
|
|
1327
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1328
|
+
result = get_sns_usernames(conn.cursor())
|
|
1329
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1330
|
+
conn.close()
|
|
1331
|
+
|
|
1332
|
+
elif args.command == 'sns-stats':
|
|
1333
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1334
|
+
result = get_sns_stats(conn.cursor(), args.my_wxid)
|
|
1335
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1336
|
+
conn.close()
|
|
1337
|
+
|
|
1338
|
+
elif args.command == 'fav-schema':
|
|
1339
|
+
result = get_fav_schema(args.db, args.key)
|
|
1340
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1341
|
+
|
|
1342
|
+
elif args.command == 'fav-list':
|
|
1343
|
+
result = get_favorites(args.db, args.key, args.limit, args.offset,
|
|
1344
|
+
args.keyword, getattr(args, 'fav_type', None))
|
|
1345
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1346
|
+
|
|
1347
|
+
else:
|
|
1348
|
+
parser.print_help()
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
if __name__ == '__main__':
|
|
1352
|
+
main()
|