@chainlesschain/personal-data-hub 0.3.9 → 0.4.0
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/__tests__/adapters/apple-health.test.js +95 -0
- package/__tests__/adapters/email-templates.test.js +123 -0
- package/__tests__/adapters/family-23-collectors-scaffold.test.js +178 -0
- package/__tests__/adapters/game-genshin-scaffold.test.js +107 -0
- package/__tests__/adapters/git-activity.test.js +7 -1
- package/__tests__/adapters/local-im-pc.test.js +149 -0
- package/__tests__/adapters/netease-music.test.js +74 -0
- package/__tests__/adapters/qq-pc-direct-read.test.js +186 -0
- package/__tests__/adapters/system-data-adapter.test.js +4 -1
- package/__tests__/adapters/wechat-pc-direct-read.test.js +207 -0
- package/__tests__/adapters/weread.test.js +123 -0
- package/__tests__/analysis.test.js +120 -15
- package/__tests__/mobile-extractor-encrypted.test.js +460 -0
- package/__tests__/prompt-builder.test.js +25 -0
- package/__tests__/registry-readiness.test.js +233 -0
- package/__tests__/social-douyin-im-direct-read.test.js +311 -0
- package/__tests__/social-douyin-snapshot.test.js +5 -2
- package/__tests__/vault.test.js +99 -0
- package/lib/adapter-guide.js +520 -0
- package/lib/adapter-readiness.js +257 -0
- package/lib/adapters/_local-im-db-reader.js +218 -0
- package/lib/adapters/_local-im-pc-adapter.js +162 -0
- package/lib/adapters/apple-health/index.js +329 -0
- package/lib/adapters/dingtalk-pc/index.js +29 -0
- package/lib/adapters/edu-huawei-learning/api-client.js +47 -0
- package/lib/adapters/edu-huawei-learning/index.js +255 -0
- package/lib/adapters/edu-zuoyebang/api-client.js +48 -0
- package/lib/adapters/edu-zuoyebang/index.js +259 -0
- package/lib/adapters/email-imap/email-adapter.js +16 -0
- package/lib/adapters/email-imap/templates/bill.js +174 -18
- package/lib/adapters/feishu-pc/index.js +29 -0
- package/lib/adapters/finance-alipay/api-client.js +48 -0
- package/lib/adapters/finance-alipay/index.js +257 -0
- package/lib/adapters/game-genshin/api-client.js +59 -0
- package/lib/adapters/game-genshin/index.js +274 -0
- package/lib/adapters/game-honor-of-kings/api-client.js +54 -0
- package/lib/adapters/game-honor-of-kings/index.js +259 -0
- package/lib/adapters/netease-music/index.js +227 -0
- package/lib/adapters/qq-pc/index.js +200 -0
- package/lib/adapters/qq-pc/nt-db-reader.js +210 -0
- package/lib/adapters/social-douyin/index.js +194 -1
- package/lib/adapters/wechat/wechat-adapter.js +7 -1
- package/lib/adapters/wechat-pc/index.js +335 -0
- package/lib/adapters/wechat-pc/pc-db-reader.js +327 -0
- package/lib/adapters/weread/api-client.js +128 -0
- package/lib/adapters/weread/index.js +337 -0
- package/lib/analysis.js +65 -0
- package/lib/index.js +39 -0
- package/lib/mobile-extractor/bplist.js +233 -0
- package/lib/mobile-extractor/ios-backup-crypto.js +315 -0
- package/lib/mobile-extractor/ios.js +131 -16
- package/lib/prompt-builder.js +11 -1
- package/lib/registry.js +170 -0
- package/lib/vault.js +105 -0
- package/package.json +1 -1
- package/scripts/run-native-tests-sandbox.sh +2 -0
- package/vitest.config.js +79 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WeChat **desktop (PC)** adapter — 本地直读样板 (ported from social-douyin's
|
|
5
|
+
* im.db direct-read). Reads PC WeChat's local SQLite DBs straight into the
|
|
6
|
+
* vault:
|
|
7
|
+
*
|
|
8
|
+
* - MSG0.db..MSGN.db → table MSG → 私信/群消息 (message events)
|
|
9
|
+
* - MicroMsg.db → table Contact → 联系人 (contact persons)
|
|
10
|
+
*
|
|
11
|
+
* Distinct from the Android `wechat` adapter (EnMicroMsg.db / frida key /
|
|
12
|
+
* message+rcontact schema). PC WeChat has its own schema + a 32-byte raw
|
|
13
|
+
* SQLCipher key. See pc-db-reader.js for the open/decrypt details.
|
|
14
|
+
*
|
|
15
|
+
* Modes (sync opts):
|
|
16
|
+
* 1. opts.dbPath / opts.inputPath — a PC WeChat DB file. If opts.key
|
|
17
|
+
* (64-hex) is supplied, the reader decrypts via SQLCipher; otherwise it
|
|
18
|
+
* expects an already-decrypted plaintext DB (the reliable, recommended
|
|
19
|
+
* path — decrypt once with PyWxDump/手动, then point here).
|
|
20
|
+
* 2. (snapshot mode reserved for a future Android-side PC-bridge; not v0.)
|
|
21
|
+
*
|
|
22
|
+
* The reader extracts whatever of {MSG, Contact} a given file has, so call
|
|
23
|
+
* sync once per file (MSG*.db for messages, MicroMsg.db for contacts).
|
|
24
|
+
*
|
|
25
|
+
* Person ids use the SAME `person-wechat-<wxid>` + `wechatId` identifier as
|
|
26
|
+
* the Android wechat adapter, so EntityResolver merges PC + Android contacts.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require("node:fs");
|
|
30
|
+
const { newId } = require("../../ids");
|
|
31
|
+
const {
|
|
32
|
+
ENTITY_TYPES,
|
|
33
|
+
PERSON_SUBTYPES,
|
|
34
|
+
EVENT_SUBTYPES,
|
|
35
|
+
CAPTURED_BY,
|
|
36
|
+
} = require("../../constants");
|
|
37
|
+
|
|
38
|
+
const NAME = "wechat-pc";
|
|
39
|
+
const VERSION = "0.1.0";
|
|
40
|
+
|
|
41
|
+
const KIND_MESSAGE = "message";
|
|
42
|
+
const KIND_CONTACT = "contact";
|
|
43
|
+
|
|
44
|
+
function stableOriginalId(kind, id) {
|
|
45
|
+
const safe =
|
|
46
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
47
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
48
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
49
|
+
return `wechat-pc:${kind}:${safe}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function wxidToPersonId(wxid) {
|
|
53
|
+
return wxid ? `person-wechat-${wxid}` : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class WeChatPcAdapter {
|
|
57
|
+
constructor(opts = {}) {
|
|
58
|
+
this._dbPath = opts.dbPath || null;
|
|
59
|
+
this._key = opts.key || null;
|
|
60
|
+
|
|
61
|
+
this.name = NAME;
|
|
62
|
+
this.version = VERSION;
|
|
63
|
+
this.capabilities = [
|
|
64
|
+
"sync:sqlite",
|
|
65
|
+
"decrypt:sqlcipher-pc",
|
|
66
|
+
"parse:wechat-pc-message",
|
|
67
|
+
"parse:wechat-pc-contact",
|
|
68
|
+
];
|
|
69
|
+
this.extractMode = "device-pull";
|
|
70
|
+
this.rateLimits = {};
|
|
71
|
+
this.dataDisclosure = {
|
|
72
|
+
fields: [
|
|
73
|
+
"wechat-pc:messages (StrTalker / StrContent / CreateTime / IsSender from MSG*.db)",
|
|
74
|
+
"wechat-pc:contacts (UserName / NickName / Remark from MicroMsg.db)",
|
|
75
|
+
],
|
|
76
|
+
sensitivity: "high",
|
|
77
|
+
legalGate: true, // chat history — first-use 法律 gate (mirrors wechat)
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this._deps = {
|
|
81
|
+
fs,
|
|
82
|
+
// DI seam: tests inject a fake SQLite driver class via dbDriverFactory.
|
|
83
|
+
dbDriverFactory: opts.dbDriverFactory || null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async authenticate(ctx = {}) {
|
|
88
|
+
// Cheap readiness probe — never opens / decrypts a DB.
|
|
89
|
+
if (ctx && ctx.readinessOnly) {
|
|
90
|
+
if (this._dbPath) return { ok: true, mode: "configured" };
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: "DB_NOT_PULLED",
|
|
94
|
+
message:
|
|
95
|
+
"wechat-pc: 需提供 PC 微信本地数据库路径(MSG*.db / MicroMsg.db),加密库需先解密或提供 key",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const dbPath = (ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._dbPath;
|
|
99
|
+
if (dbPath) {
|
|
100
|
+
try {
|
|
101
|
+
this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: "INPUT_PATH_UNREADABLE",
|
|
106
|
+
message: `wechat-pc: db not readable at ${dbPath}: ${err.message}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, mode: "sqlite" };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: "DB_NOT_PULLED",
|
|
114
|
+
message: "wechat-pc.authenticate: needs opts.dbPath / inputPath (MSG*.db or MicroMsg.db)",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async healthCheck() {
|
|
119
|
+
return { ok: true, lastChecked: Date.now() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async *sync(opts = {}) {
|
|
123
|
+
const dbPath = opts.dbPath || opts.inputPath || this._dbPath;
|
|
124
|
+
if (!dbPath) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"wechat-pc.sync: needs opts.dbPath / opts.inputPath pointing to a PC WeChat DB (MSG*.db or MicroMsg.db)",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!this._deps.fs.existsSync(dbPath)) return;
|
|
130
|
+
|
|
131
|
+
// eslint-disable-next-line global-require
|
|
132
|
+
const { readPcWeChat } = require("./pc-db-reader");
|
|
133
|
+
const readOpts = { key: opts.key || this._key || null };
|
|
134
|
+
if (Number.isInteger(opts.limitMessages)) readOpts.limitMessages = opts.limitMessages;
|
|
135
|
+
if (Number.isInteger(opts.limitContacts)) readOpts.limitContacts = opts.limitContacts;
|
|
136
|
+
if (this._deps.dbDriverFactory) readOpts._databaseClass = this._deps.dbDriverFactory();
|
|
137
|
+
|
|
138
|
+
const { messages, contacts, diagnostic } = readPcWeChat(dbPath, readOpts);
|
|
139
|
+
if (typeof opts.onProgress === "function") {
|
|
140
|
+
try {
|
|
141
|
+
opts.onProgress({ phase: "pc-db-read", adapter: NAME, ...diagnostic });
|
|
142
|
+
} catch (_e) { /* progress best-effort */ }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const include = opts.include || {};
|
|
146
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
147
|
+
const fallbackCapturedAt = Date.now();
|
|
148
|
+
let emitted = 0;
|
|
149
|
+
|
|
150
|
+
if (include[KIND_MESSAGE] !== false) {
|
|
151
|
+
for (const m of messages) {
|
|
152
|
+
if (emitted >= limit) return;
|
|
153
|
+
if (!m || typeof m !== "object") continue;
|
|
154
|
+
const capturedAt =
|
|
155
|
+
typeof m.createdTimeMs === "number" && m.createdTimeMs > 0
|
|
156
|
+
? m.createdTimeMs
|
|
157
|
+
: fallbackCapturedAt;
|
|
158
|
+
// Composite id: msgSvrId is globally unique; fallback to talker+time.
|
|
159
|
+
const idPart =
|
|
160
|
+
m.msgSvrId ||
|
|
161
|
+
(m.talker && m.createdTimeMs ? `${m.talker}-${m.createdTimeMs}` : `msg-${emitted}`);
|
|
162
|
+
yield {
|
|
163
|
+
adapter: NAME,
|
|
164
|
+
kind: KIND_MESSAGE,
|
|
165
|
+
originalId: stableOriginalId(KIND_MESSAGE, idPart),
|
|
166
|
+
capturedAt,
|
|
167
|
+
payload: { kind: KIND_MESSAGE, ...m },
|
|
168
|
+
};
|
|
169
|
+
emitted += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (include[KIND_CONTACT] !== false) {
|
|
174
|
+
for (const c of contacts) {
|
|
175
|
+
if (emitted >= limit) return;
|
|
176
|
+
if (!c || typeof c !== "object" || !c.wxid) continue;
|
|
177
|
+
yield {
|
|
178
|
+
adapter: NAME,
|
|
179
|
+
kind: KIND_CONTACT,
|
|
180
|
+
originalId: stableOriginalId(KIND_CONTACT, c.wxid),
|
|
181
|
+
capturedAt: fallbackCapturedAt,
|
|
182
|
+
payload: { kind: KIND_CONTACT, ...c },
|
|
183
|
+
};
|
|
184
|
+
emitted += 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
normalize(raw) {
|
|
190
|
+
if (!raw || !raw.payload) {
|
|
191
|
+
throw new Error("WeChatPcAdapter.normalize: payload missing");
|
|
192
|
+
}
|
|
193
|
+
const kind = raw.kind || raw.payload.kind;
|
|
194
|
+
const ingestedAt = Date.now();
|
|
195
|
+
if (kind === KIND_MESSAGE) return normalizeMessage(raw.payload, raw, ingestedAt);
|
|
196
|
+
if (kind === KIND_CONTACT) return normalizeContact(raw.payload, raw, ingestedAt);
|
|
197
|
+
throw new Error(`WeChatPcAdapter.normalize: unknown kind ${kind}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildSource(raw, occurredAt) {
|
|
202
|
+
return {
|
|
203
|
+
adapter: NAME,
|
|
204
|
+
adapterVersion: VERSION,
|
|
205
|
+
originalId: raw.originalId,
|
|
206
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
207
|
+
capturedBy: CAPTURED_BY.SQLITE,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeMessage(p, raw, ingestedAt) {
|
|
212
|
+
const occurredAt =
|
|
213
|
+
(typeof p.createdTimeMs === "number" && p.createdTimeMs) || raw.capturedAt || ingestedAt;
|
|
214
|
+
const source = buildSource(raw, occurredAt);
|
|
215
|
+
const text = typeof p.text === "string" ? p.text : "";
|
|
216
|
+
const isSend = Number(p.isSend) === 1;
|
|
217
|
+
const isGroup = !!p.isGroup;
|
|
218
|
+
// actor: outbound = self; inbound 1-on-1 = talker; group = sender prefix.
|
|
219
|
+
const selfId = "person-wechat-self";
|
|
220
|
+
let actor;
|
|
221
|
+
if (isGroup) {
|
|
222
|
+
actor = p.senderWxid ? wxidToPersonId(p.senderWxid) : selfId;
|
|
223
|
+
} else {
|
|
224
|
+
actor = isSend ? selfId : (p.talker ? wxidToPersonId(p.talker) : selfId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const persons = [];
|
|
228
|
+
if (!isGroup && p.talker && !p.talker.endsWith("@chatroom")) {
|
|
229
|
+
persons.push({
|
|
230
|
+
id: wxidToPersonId(p.talker),
|
|
231
|
+
type: ENTITY_TYPES.PERSON,
|
|
232
|
+
subtype: PERSON_SUBTYPES.CONTACT,
|
|
233
|
+
names: [p.talker],
|
|
234
|
+
ingestedAt,
|
|
235
|
+
source,
|
|
236
|
+
identifiers: { wechatId: p.talker },
|
|
237
|
+
extra: { platform: "wechat", source: "pc", wxid: p.talker },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (isGroup && p.senderWxid) {
|
|
241
|
+
persons.push({
|
|
242
|
+
id: wxidToPersonId(p.senderWxid),
|
|
243
|
+
type: ENTITY_TYPES.PERSON,
|
|
244
|
+
subtype: PERSON_SUBTYPES.CONTACT,
|
|
245
|
+
names: [p.senderWxid],
|
|
246
|
+
ingestedAt,
|
|
247
|
+
source,
|
|
248
|
+
identifiers: { wechatId: p.senderWxid },
|
|
249
|
+
extra: { platform: "wechat", source: "pc", wxid: p.senderWxid },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const topics = [];
|
|
254
|
+
if (isGroup && p.talker) {
|
|
255
|
+
topics.push({
|
|
256
|
+
id: `topic-wechat-group-${p.talker}`,
|
|
257
|
+
type: ENTITY_TYPES.TOPIC,
|
|
258
|
+
name: p.talker.replace("@chatroom", ""),
|
|
259
|
+
ingestedAt,
|
|
260
|
+
source,
|
|
261
|
+
extra: { platform: "wechat", source: "pc", wxid: p.talker },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
events: [{
|
|
267
|
+
id: newId(),
|
|
268
|
+
type: ENTITY_TYPES.EVENT,
|
|
269
|
+
subtype: EVENT_SUBTYPES.MESSAGE,
|
|
270
|
+
occurredAt,
|
|
271
|
+
actor: actor || selfId,
|
|
272
|
+
content: {
|
|
273
|
+
title: text ? text.slice(0, 80) : "(非文本消息)",
|
|
274
|
+
text,
|
|
275
|
+
},
|
|
276
|
+
ingestedAt,
|
|
277
|
+
source,
|
|
278
|
+
extra: {
|
|
279
|
+
platform: "wechat",
|
|
280
|
+
source: "pc",
|
|
281
|
+
talker: p.talker || null,
|
|
282
|
+
isSend,
|
|
283
|
+
isGroup,
|
|
284
|
+
wechatType: typeof p.type === "number" ? p.type : null,
|
|
285
|
+
senderWxid: p.senderWxid || null,
|
|
286
|
+
contentBlob: typeof p.contentBlob === "string" ? p.contentBlob : null,
|
|
287
|
+
...(topics.length ? { topicId: topics[0].id } : {}),
|
|
288
|
+
},
|
|
289
|
+
}],
|
|
290
|
+
persons,
|
|
291
|
+
places: [],
|
|
292
|
+
items: [],
|
|
293
|
+
topics,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeContact(p, raw, ingestedAt) {
|
|
298
|
+
const wxid = p.wxid ? String(p.wxid) : null;
|
|
299
|
+
const occurredAt = raw.capturedAt || ingestedAt;
|
|
300
|
+
const source = buildSource(raw, occurredAt);
|
|
301
|
+
const names = [p.remark, p.nickname, p.alias, wxid].filter(
|
|
302
|
+
(n) => typeof n === "string" && n.length > 0,
|
|
303
|
+
);
|
|
304
|
+
const subtype =
|
|
305
|
+
wxid && wxid.startsWith("gh_") ? PERSON_SUBTYPES.MERCHANT : PERSON_SUBTYPES.CONTACT;
|
|
306
|
+
return {
|
|
307
|
+
events: [],
|
|
308
|
+
persons: [{
|
|
309
|
+
id: wxidToPersonId(wxid) || `person-wechat-${newId()}`,
|
|
310
|
+
type: ENTITY_TYPES.PERSON,
|
|
311
|
+
subtype,
|
|
312
|
+
names: names.length ? names : ["(unnamed)"],
|
|
313
|
+
ingestedAt,
|
|
314
|
+
source,
|
|
315
|
+
identifiers: wxid ? { wechatId: wxid } : {},
|
|
316
|
+
extra: {
|
|
317
|
+
platform: "wechat",
|
|
318
|
+
source: "pc",
|
|
319
|
+
wxid,
|
|
320
|
+
alias: p.alias || null,
|
|
321
|
+
wechatType: typeof p.type === "number" ? p.type : null,
|
|
322
|
+
},
|
|
323
|
+
}],
|
|
324
|
+
places: [],
|
|
325
|
+
items: [],
|
|
326
|
+
topics: [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = {
|
|
331
|
+
WeChatPcAdapter,
|
|
332
|
+
NAME,
|
|
333
|
+
VERSION,
|
|
334
|
+
wxidToPersonId,
|
|
335
|
+
};
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WeChat **desktop (PC)** local-DB direct reader — 本地直读样板, ported from
|
|
5
|
+
* the Douyin im.db sample (social-douyin-adb/im-db-parser.js) to PC WeChat.
|
|
6
|
+
*
|
|
7
|
+
* KEY DIFFERENCE from the Android `wechat/` adapter:
|
|
8
|
+
* - Android WeChat: ONE EnMicroMsg.db, tables `message` / `rcontact` /
|
|
9
|
+
* `chatroom`, key = MD5(IMEI+UIN) or frida.
|
|
10
|
+
* - PC WeChat 3.x: messages live in `MSG0.db`..`MSGN.db` (table `MSG`),
|
|
11
|
+
* contacts + groups live in `MicroMsg.db` (tables `Contact` / `ChatRoom`).
|
|
12
|
+
* Encrypted with SQLCipher using a 32-byte (64-hex) raw key extracted
|
|
13
|
+
* from the running WeChat.exe process memory.
|
|
14
|
+
*
|
|
15
|
+
* Two ways in (mirrors the user's "愿意本地解密" stance):
|
|
16
|
+
*
|
|
17
|
+
* 1. PLAINTEXT (recommended, fully reliable + testable): the user decrypts
|
|
18
|
+
* the PC DB to a plain SQLite file first (PyWxDump / 手动 SQLCipher
|
|
19
|
+
* decrypt) and points the adapter at it. WeChat PC's SQLCipher uses a
|
|
20
|
+
* non-standard per-page HMAC salt scheme, so "decrypt-to-plaintext
|
|
21
|
+
* then read" is the proven path — same shape as the Douyin plaintext
|
|
22
|
+
* im.db sample.
|
|
23
|
+
*
|
|
24
|
+
* 2. ENCRYPTED + raw key (best-effort): if a 64-hex key is supplied we try
|
|
25
|
+
* the documented PC SQLCipher PRAGMA profile. Some WeChat builds open
|
|
26
|
+
* cleanly this way; if not, fall back to method 1.
|
|
27
|
+
*
|
|
28
|
+
* This reader does NOT extract the key (that needs OS-specific process-memory
|
|
29
|
+
* scanning — out of scope here; the guide points at the manual step). It
|
|
30
|
+
* opens what it's given and reads whatever of {MSG, Contact, ChatRoom} the
|
|
31
|
+
* file contains, so the SAME reader serves both MSG*.db (messages) and
|
|
32
|
+
* MicroMsg.db (contacts) — point it at each in turn.
|
|
33
|
+
*
|
|
34
|
+
* Test seam: inject a synthetic `_databaseClass` to bypass the native
|
|
35
|
+
* dual-load (mirrors douyin im-db-parser + bilibili chromium-cookies-reader).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// PC WeChat 3.x SQLCipher params (documented by PyWxDump et al.). Applied
|
|
39
|
+
// only when a raw key is supplied. cipher_compatibility 3 ≈ WeChat's
|
|
40
|
+
// page_size 4096 / kdf_iter 64000 / HMAC-SHA1 layout. Tried in order.
|
|
41
|
+
const KNOWN_PC_PRAGMA_PROFILES = Object.freeze([
|
|
42
|
+
{
|
|
43
|
+
name: "wechat-pc-v3",
|
|
44
|
+
pragmas: [
|
|
45
|
+
"PRAGMA cipher_compatibility = 3",
|
|
46
|
+
"PRAGMA cipher_page_size = 4096",
|
|
47
|
+
"PRAGMA kdf_iter = 64000",
|
|
48
|
+
"PRAGMA cipher_hmac_algorithm = HMAC_SHA1",
|
|
49
|
+
"PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA1",
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "wechat-pc-v4",
|
|
54
|
+
pragmas: [
|
|
55
|
+
"PRAGMA cipher_compatibility = 4",
|
|
56
|
+
"PRAGMA cipher_page_size = 4096",
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function loadDatabaseClass() {
|
|
62
|
+
for (const mod of ["better-sqlite3-multiple-ciphers", "better-sqlite3"]) {
|
|
63
|
+
let cls;
|
|
64
|
+
try {
|
|
65
|
+
// eslint-disable-next-line global-require
|
|
66
|
+
cls = require(mod);
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const probe = new cls(":memory:");
|
|
72
|
+
probe.close();
|
|
73
|
+
return cls;
|
|
74
|
+
} catch (_e) {
|
|
75
|
+
// ABI mismatch — try next
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error(
|
|
79
|
+
"wechat-pc-db-reader: neither better-sqlite3-multiple-ciphers nor better-sqlite3 loaded — both ABI-mismatched",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function trySelect(db, sql) {
|
|
84
|
+
try {
|
|
85
|
+
return db.prepare(sql).all();
|
|
86
|
+
} catch (_e) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pickCol(columns, candidates) {
|
|
92
|
+
for (const c of candidates) {
|
|
93
|
+
if (columns.has(c)) return c;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize PC WeChat timestamps to ms. PC stores CreateTime in SECONDS
|
|
100
|
+
* (10-digit). Be defensive about ms/µs the same way Douyin is.
|
|
101
|
+
*/
|
|
102
|
+
function normalizeEpochMs(v) {
|
|
103
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return null;
|
|
104
|
+
if (v > 1e15) return Math.floor(v / 1000); // µs
|
|
105
|
+
if (v > 1e12) return Math.floor(v); // ms
|
|
106
|
+
return Math.floor(v * 1000); // seconds
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* For Type=1 (plain text) messages StrContent IS the text. For group
|
|
111
|
+
* messages PC prefixes the sender wxid + ":\n" inside StrContent; strip it
|
|
112
|
+
* so the text is clean and expose the sender separately.
|
|
113
|
+
*/
|
|
114
|
+
function parsePcContent(strContent, isGroup) {
|
|
115
|
+
if (typeof strContent !== "string" || strContent.length === 0) {
|
|
116
|
+
return { text: "", senderWxid: null };
|
|
117
|
+
}
|
|
118
|
+
if (isGroup) {
|
|
119
|
+
const idx = strContent.indexOf(":\n");
|
|
120
|
+
if (idx > 0 && idx < 80) {
|
|
121
|
+
return {
|
|
122
|
+
senderWxid: strContent.slice(0, idx),
|
|
123
|
+
text: strContent.slice(idx + 2),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { text: strContent, senderWxid: null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isGroupTalker(talker) {
|
|
131
|
+
return typeof talker === "string" && talker.endsWith("@chatroom");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Open a PC WeChat DB (encrypted-with-key OR already-plaintext).
|
|
136
|
+
*
|
|
137
|
+
* @param {string} dbPath
|
|
138
|
+
* @param {{ key?: string, _databaseClass?: any }} [opts]
|
|
139
|
+
* @returns {{ db: object, mode: "plaintext"|"sqlcipher", profile: string|null }}
|
|
140
|
+
*/
|
|
141
|
+
function openPcWeChatDb(dbPath, opts = {}) {
|
|
142
|
+
const Database = opts._databaseClass || loadDatabaseClass();
|
|
143
|
+
const key = typeof opts.key === "string" && opts.key.length > 0 ? opts.key : null;
|
|
144
|
+
|
|
145
|
+
if (!key) {
|
|
146
|
+
// Plaintext path — already-decrypted db. Probe sqlite_master.
|
|
147
|
+
const db = new Database(dbPath, { readonly: true });
|
|
148
|
+
try {
|
|
149
|
+
db.prepare("SELECT count(*) AS n FROM sqlite_master").get();
|
|
150
|
+
return { db, mode: "plaintext", profile: null };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
try { db.close(); } catch (_e) { /* ignore */ }
|
|
153
|
+
const e = new Error(
|
|
154
|
+
`wechat-pc-db-reader: db is not plaintext SQLite (supply a 64-hex key, or decrypt first): ${err.message}`,
|
|
155
|
+
);
|
|
156
|
+
e.code = "WECHAT_PC_NEEDS_KEY";
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// SQLCipher path — raw hex key. Try each known PC profile.
|
|
162
|
+
const keyExpr = /^[0-9a-fA-F]{64}$/.test(key) ? `"x'${key}'"` : `'${key}'`;
|
|
163
|
+
let lastError = null;
|
|
164
|
+
for (const profile of KNOWN_PC_PRAGMA_PROFILES) {
|
|
165
|
+
let db;
|
|
166
|
+
try {
|
|
167
|
+
db = new Database(dbPath, { readonly: true });
|
|
168
|
+
db.pragma(`key = ${keyExpr}`);
|
|
169
|
+
for (const p of profile.pragmas) db.exec(p);
|
|
170
|
+
const row = db.prepare("SELECT count(*) AS n FROM sqlite_master").get();
|
|
171
|
+
if (row && Number.isFinite(row.n)) {
|
|
172
|
+
return { db, mode: "sqlcipher", profile: profile.name };
|
|
173
|
+
}
|
|
174
|
+
db.close();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
lastError = err;
|
|
177
|
+
if (db) {
|
|
178
|
+
try { db.close(); } catch (_e) { /* ignore */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const e = new Error(
|
|
183
|
+
`wechat-pc-db-reader: failed to open with any SQLCipher profile — ` +
|
|
184
|
+
`key wrong, or WeChat build needs decrypt-to-plaintext first. ` +
|
|
185
|
+
`Last error: ${lastError && lastError.message}`,
|
|
186
|
+
);
|
|
187
|
+
e.code = "WECHAT_PC_DECRYPT_FAILED";
|
|
188
|
+
throw e;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read messages + contacts out of a PC WeChat DB. Reads whatever of
|
|
193
|
+
* {MSG, Contact} the file contains (MSG*.db has MSG; MicroMsg.db has
|
|
194
|
+
* Contact), so the same call works for either file.
|
|
195
|
+
*
|
|
196
|
+
* Returns `{ messages, contacts, diagnostic }`:
|
|
197
|
+
* messages: {msgSvrId, talker, isSend, createdTimeMs, type, text,
|
|
198
|
+
* senderWxid, isGroup, contentBlob}
|
|
199
|
+
* contacts: {wxid, alias, nickname, remark, type}
|
|
200
|
+
* diagnostic: {messageCount, contactCount, hadMsgTable, hadContactTable,
|
|
201
|
+
* mode, profile}
|
|
202
|
+
*
|
|
203
|
+
* @param {string} dbPath
|
|
204
|
+
* @param {{key?, _databaseClass?, limitMessages?, limitContacts?}} [opts]
|
|
205
|
+
*/
|
|
206
|
+
function readPcWeChat(dbPath, opts = {}) {
|
|
207
|
+
if (typeof dbPath !== "string" || dbPath.length === 0) {
|
|
208
|
+
throw new TypeError("readPcWeChat: dbPath must be a non-empty string");
|
|
209
|
+
}
|
|
210
|
+
const limitMessages =
|
|
211
|
+
Number.isInteger(opts.limitMessages) && opts.limitMessages > 0
|
|
212
|
+
? opts.limitMessages
|
|
213
|
+
: 20_000;
|
|
214
|
+
const limitContacts =
|
|
215
|
+
Number.isInteger(opts.limitContacts) && opts.limitContacts > 0
|
|
216
|
+
? opts.limitContacts
|
|
217
|
+
: 10_000;
|
|
218
|
+
|
|
219
|
+
const { db, mode, profile } = openPcWeChatDb(dbPath, opts);
|
|
220
|
+
const out = {
|
|
221
|
+
messages: [],
|
|
222
|
+
contacts: [],
|
|
223
|
+
diagnostic: {
|
|
224
|
+
messageCount: 0,
|
|
225
|
+
contactCount: 0,
|
|
226
|
+
hadMsgTable: false,
|
|
227
|
+
hadContactTable: false,
|
|
228
|
+
mode,
|
|
229
|
+
profile,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
try {
|
|
233
|
+
// ─── MSG table (messages) ────────────────────────────────────────────
|
|
234
|
+
const msgInfo = trySelect(db, "PRAGMA table_info(MSG)");
|
|
235
|
+
if (Array.isArray(msgInfo) && msgInfo.length > 0) {
|
|
236
|
+
out.diagnostic.hadMsgTable = true;
|
|
237
|
+
const cols = new Set(msgInfo.map((r) => r.name));
|
|
238
|
+
const svrCol = pickCol(cols, ["MsgSvrID", "msgSvrId", "MsgSvrId"]);
|
|
239
|
+
const talkerCol = pickCol(cols, ["StrTalker", "strTalker", "Talker", "talker"]);
|
|
240
|
+
const sendCol = pickCol(cols, ["IsSender", "isSend", "IsSend"]);
|
|
241
|
+
const timeCol = pickCol(cols, ["CreateTime", "createTime", "create_time"]);
|
|
242
|
+
const typeCol = pickCol(cols, ["Type", "type", "MsgType"]);
|
|
243
|
+
const contentCol = pickCol(cols, ["StrContent", "strContent", "Content", "content"]);
|
|
244
|
+
const localIdCol = pickCol(cols, ["localId", "MsgId", "msgId"]);
|
|
245
|
+
if (timeCol && contentCol) {
|
|
246
|
+
const fields = [];
|
|
247
|
+
if (svrCol) fields.push(`${svrCol} AS msgSvrId`);
|
|
248
|
+
if (localIdCol) fields.push(`${localIdCol} AS localId`);
|
|
249
|
+
if (talkerCol) fields.push(`${talkerCol} AS talker`);
|
|
250
|
+
if (sendCol) fields.push(`${sendCol} AS isSend`);
|
|
251
|
+
if (typeCol) fields.push(`${typeCol} AS type`);
|
|
252
|
+
fields.push(`${timeCol} AS createTime`);
|
|
253
|
+
fields.push(`${contentCol} AS content`);
|
|
254
|
+
const sql =
|
|
255
|
+
`SELECT ${fields.join(", ")} FROM MSG ORDER BY ${timeCol} DESC LIMIT ${limitMessages}`;
|
|
256
|
+
const rows = trySelect(db, sql) || [];
|
|
257
|
+
for (const r of rows) {
|
|
258
|
+
const isGroup = isGroupTalker(r.talker);
|
|
259
|
+
const { text, senderWxid } = parsePcContent(r.content, isGroup);
|
|
260
|
+
out.messages.push({
|
|
261
|
+
msgSvrId: r.msgSvrId != null ? String(r.msgSvrId) : (r.localId != null ? `local-${r.localId}` : null),
|
|
262
|
+
talker: r.talker ? String(r.talker) : null,
|
|
263
|
+
isSend: typeof r.isSend === "number" ? r.isSend : null,
|
|
264
|
+
createdTimeMs: normalizeEpochMs(r.createTime),
|
|
265
|
+
type: typeof r.type === "number" ? r.type : null,
|
|
266
|
+
text,
|
|
267
|
+
senderWxid,
|
|
268
|
+
isGroup,
|
|
269
|
+
contentBlob: typeof r.content === "string" ? r.content : null,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
out.diagnostic.messageCount = out.messages.length;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Contact table (contacts) ────────────────────────────────────────
|
|
277
|
+
const contactInfo = trySelect(db, "PRAGMA table_info(Contact)");
|
|
278
|
+
if (Array.isArray(contactInfo) && contactInfo.length > 0) {
|
|
279
|
+
out.diagnostic.hadContactTable = true;
|
|
280
|
+
const cols = new Set(contactInfo.map((r) => r.name));
|
|
281
|
+
const wxidCol = pickCol(cols, ["UserName", "userName", "Username", "username"]);
|
|
282
|
+
const aliasCol = pickCol(cols, ["Alias", "alias"]);
|
|
283
|
+
const nickCol = pickCol(cols, ["NickName", "nickName", "nickname"]);
|
|
284
|
+
const remarkCol = pickCol(cols, ["Remark", "remark", "ConRemark", "conRemark"]);
|
|
285
|
+
const typeCol = pickCol(cols, ["Type", "type"]);
|
|
286
|
+
if (wxidCol) {
|
|
287
|
+
const fields = [`${wxidCol} AS wxid`];
|
|
288
|
+
if (aliasCol) fields.push(`${aliasCol} AS alias`);
|
|
289
|
+
if (nickCol) fields.push(`${nickCol} AS nickname`);
|
|
290
|
+
if (remarkCol) fields.push(`${remarkCol} AS remark`);
|
|
291
|
+
if (typeCol) fields.push(`${typeCol} AS type`);
|
|
292
|
+
const sql = `SELECT ${fields.join(", ")} FROM Contact LIMIT ${limitContacts}`;
|
|
293
|
+
const rows = trySelect(db, sql) || [];
|
|
294
|
+
for (const r of rows) {
|
|
295
|
+
if (!r.wxid) continue;
|
|
296
|
+
const wxid = String(r.wxid);
|
|
297
|
+
// Skip WeChat internal placeholders + chatrooms (not Persons).
|
|
298
|
+
if (wxid.endsWith("@chatroom")) continue;
|
|
299
|
+
out.contacts.push({
|
|
300
|
+
wxid,
|
|
301
|
+
alias: r.alias || null,
|
|
302
|
+
nickname: r.nickname || null,
|
|
303
|
+
remark: r.remark || null,
|
|
304
|
+
type: typeof r.type === "number" ? r.type : null,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
out.diagnostic.contactCount = out.contacts.length;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
try { db.close(); } catch (_e) { /* ignore */ }
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
readPcWeChat,
|
|
318
|
+
openPcWeChatDb,
|
|
319
|
+
KNOWN_PC_PRAGMA_PROFILES,
|
|
320
|
+
_internals: {
|
|
321
|
+
loadDatabaseClass,
|
|
322
|
+
normalizeEpochMs,
|
|
323
|
+
parsePcContent,
|
|
324
|
+
isGroupTalker,
|
|
325
|
+
pickCol,
|
|
326
|
+
},
|
|
327
|
+
};
|