@chainlesschain/personal-data-hub 0.4.0 → 0.4.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/README.md +45 -25
- package/__tests__/adapter-guide.test.js +47 -0
- package/__tests__/adapters/local-im-pc.test.js +7 -2
- package/__tests__/adapters/pc-local-discovery.test.js +141 -0
- package/__tests__/adapters/qq-pc-direct-read.test.js +7 -2
- package/__tests__/adapters/social-douyin-adb-db-extension.test.js +114 -0
- package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +167 -0
- package/__tests__/adapters/wechat-pc-direct-read.test.js +160 -2
- package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +72 -0
- package/__tests__/registry-readiness.test.js +59 -0
- package/lib/adapter-guide.js +43 -10
- package/lib/adapter-readiness.js +23 -0
- package/lib/adapters/_local-im-pc-adapter.js +34 -5
- package/lib/adapters/_pc-local-discovery.js +362 -0
- package/lib/adapters/qq-pc/index.js +47 -8
- package/lib/adapters/social-douyin-adb/db-extension.js +66 -4
- package/lib/adapters/social-weibo-adb/cookies-extension.js +33 -6
- package/lib/adapters/wechat-pc/index.js +182 -8
- package/lib/adapters/wechat-pc/v4-sidecar.js +112 -0
- package/lib/registry.js +78 -2
- package/package.json +1 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PC local-data auto-discovery — find a desktop App's local SQLite database(s)
|
|
5
|
+
* on the host WITHOUT the user having to type a path.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: every device-pull desktop IM adapter (wechat-pc / qq-pc /
|
|
8
|
+
* dingtalk-pc / feishu-pc) used to require an explicit `opts.dbPath`. With no
|
|
9
|
+
* path the readiness probe reported `DB_NOT_PULLED` and the UI offered no way
|
|
10
|
+
* to start — so "一键采集" was impossible for these sources even though the
|
|
11
|
+
* database sits at a well-known location on the very machine running cc.
|
|
12
|
+
*
|
|
13
|
+
* This module scans the known default install / data directories for each
|
|
14
|
+
* supported App and returns the discovered database files, grouped by the
|
|
15
|
+
* login account, with an `encrypted` flag and the *primary* message DB picked
|
|
16
|
+
* out. Adapters call it from `authenticate()` (readiness) and `sync()` (auto
|
|
17
|
+
* path) so the common case becomes truly one-click: install the App, log in,
|
|
18
|
+
* click 采集.
|
|
19
|
+
*
|
|
20
|
+
* Layout knowledge is version-aware where it matters:
|
|
21
|
+
* - WeChat 4.x: ~/Documents/xwechat_files/<wxid>_<n>/db_storage/...
|
|
22
|
+
* - WeChat 3.x: ~/Documents/WeChat Files/<wxid>/Msg/...
|
|
23
|
+
* - QQ NT: ~/Documents/Tencent Files/<uin>/nt_qq/nt_db/nt_msg.db
|
|
24
|
+
* - DingTalk: %APPDATA%/DingTalk/**, ~/Documents/DingTalk/** (best-effort)
|
|
25
|
+
* - Feishu/Lark: %APPDATA%/Feishu | Lark /** (best-effort)
|
|
26
|
+
*
|
|
27
|
+
* Pure + dependency-injectable: pass { fs, path, platform, home, env } so the
|
|
28
|
+
* whole thing is unit-testable against a synthetic filesystem with no real
|
|
29
|
+
* Apps installed. Defaults read the real host.
|
|
30
|
+
*
|
|
31
|
+
* Everything is best-effort and defensive: a missing directory, a permission
|
|
32
|
+
* error, or an unexpected layout yields `{ installed: false }` rather than
|
|
33
|
+
* throwing — discovery must never break a readiness probe.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const nodeFs = require("node:fs");
|
|
37
|
+
const nodePath = require("node:path");
|
|
38
|
+
const nodeOs = require("node:os");
|
|
39
|
+
|
|
40
|
+
/** App keys this module knows how to discover. */
|
|
41
|
+
const SUPPORTED_APPS = Object.freeze([
|
|
42
|
+
"wechat-pc",
|
|
43
|
+
"qq-pc",
|
|
44
|
+
"dingtalk-pc",
|
|
45
|
+
"feishu-pc",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// ─── injectable host accessors ─────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function makeDeps(deps = {}) {
|
|
51
|
+
const fs = deps.fs || nodeFs;
|
|
52
|
+
const path = deps.path || nodePath;
|
|
53
|
+
const platform = deps.platform || process.platform;
|
|
54
|
+
const home =
|
|
55
|
+
deps.home || (deps.os && deps.os.homedir ? deps.os.homedir() : nodeOs.homedir());
|
|
56
|
+
const env = deps.env || process.env;
|
|
57
|
+
return { fs, path, platform, home, env };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeReaddir(fs, dir) {
|
|
61
|
+
try {
|
|
62
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
} catch (_e) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeExists(fs, p) {
|
|
69
|
+
try {
|
|
70
|
+
return fs.existsSync(p);
|
|
71
|
+
} catch (_e) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeSize(fs, p) {
|
|
77
|
+
try {
|
|
78
|
+
return fs.statSync(p).size;
|
|
79
|
+
} catch (_e) {
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** List immediate subdirectory names of `dir` (empty array on any error). */
|
|
85
|
+
function listSubdirs(fs, path, dir) {
|
|
86
|
+
return safeReaddir(fs, dir)
|
|
87
|
+
.filter((e) => e.isDirectory())
|
|
88
|
+
.map((e) => e.name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recursively collect *.db / *.sqlite / *.sqlite3 files under `root` up to
|
|
93
|
+
* `maxDepth`. Used for best-effort Apps (DingTalk / Feishu) whose internal
|
|
94
|
+
* layout is proprietary and version-volatile. Bounded so a huge tree can't
|
|
95
|
+
* stall a readiness probe.
|
|
96
|
+
*/
|
|
97
|
+
function collectDbFiles(fs, path, root, maxDepth, out, depth) {
|
|
98
|
+
if (depth > maxDepth) return out;
|
|
99
|
+
for (const e of safeReaddir(fs, root)) {
|
|
100
|
+
const fp = path.join(root, e.name);
|
|
101
|
+
if (e.isFile() && /\.(db|sqlite|sqlite3)$/i.test(e.name)) {
|
|
102
|
+
out.push(fp);
|
|
103
|
+
} else if (e.isDirectory()) {
|
|
104
|
+
collectDbFiles(fs, path, fp, maxDepth, out, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── per-App discovery ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* WeChat desktop — handles BOTH the 4.x (`xwechat_files`) and 3.x
|
|
114
|
+
* (`WeChat Files`) layouts. Both store SQLCipher-encrypted DBs.
|
|
115
|
+
*/
|
|
116
|
+
function discoverWeChat({ fs, path, home, env }) {
|
|
117
|
+
const accounts = [];
|
|
118
|
+
let layout = null;
|
|
119
|
+
|
|
120
|
+
// ── 4.x: ~/Documents/xwechat_files/<wxid>_<n>/db_storage/ ──
|
|
121
|
+
const v4Root = path.join(home, "Documents", "xwechat_files");
|
|
122
|
+
if (safeExists(fs, v4Root)) {
|
|
123
|
+
for (const name of listSubdirs(fs, path, v4Root)) {
|
|
124
|
+
// account dirs look like wxid_xxx_1234 ; skip all_users / login etc.
|
|
125
|
+
if (!/^wxid_/.test(name)) continue;
|
|
126
|
+
const storage = path.join(v4Root, name, "db_storage");
|
|
127
|
+
if (!safeExists(fs, storage)) continue;
|
|
128
|
+
const dbs = [];
|
|
129
|
+
const msgDir = path.join(storage, "message");
|
|
130
|
+
for (const e of safeReaddir(fs, msgDir)) {
|
|
131
|
+
if (e.isFile() && /^message_\d+\.db$/i.test(e.name)) {
|
|
132
|
+
dbs.push(mkDb(path.join(msgDir, e.name), "message", "私聊/群聊消息", true, fs));
|
|
133
|
+
}
|
|
134
|
+
if (e.isFile() && /^biz_message_\d+\.db$/i.test(e.name)) {
|
|
135
|
+
dbs.push(mkDb(path.join(msgDir, e.name), "biz-message", "公众号消息", true, fs));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const contactDb = path.join(storage, "contact", "contact.db");
|
|
139
|
+
if (safeExists(fs, contactDb)) {
|
|
140
|
+
dbs.push(mkDb(contactDb, "contact", "联系人", true, fs));
|
|
141
|
+
}
|
|
142
|
+
const snsDb = path.join(storage, "sns", "sns.db");
|
|
143
|
+
if (safeExists(fs, snsDb)) dbs.push(mkDb(snsDb, "sns", "朋友圈", true, fs));
|
|
144
|
+
const favDb = path.join(storage, "favorite", "favorite.db");
|
|
145
|
+
if (safeExists(fs, favDb)) dbs.push(mkDb(favDb, "favorite", "收藏", true, fs));
|
|
146
|
+
if (dbs.length > 0) {
|
|
147
|
+
layout = "4.x";
|
|
148
|
+
accounts.push({ id: name.replace(/_\d+$/, ""), root: path.join(v4Root, name), dbs });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── 3.x: ~/Documents/WeChat Files/<wxid>/Msg/ ──
|
|
154
|
+
const v3Roots = [
|
|
155
|
+
path.join(home, "Documents", "WeChat Files"),
|
|
156
|
+
env.APPDATA ? path.join(env.APPDATA, "Tencent", "WeChat", "WeChat Files") : null,
|
|
157
|
+
].filter(Boolean);
|
|
158
|
+
for (const root of v3Roots) {
|
|
159
|
+
if (!safeExists(fs, root)) continue;
|
|
160
|
+
for (const name of listSubdirs(fs, path, root)) {
|
|
161
|
+
if (name === "All Users" || name === "Applet" || name === "WMPF") continue;
|
|
162
|
+
const msgDir = path.join(root, name, "Msg");
|
|
163
|
+
const multiDir = path.join(msgDir, "Multi");
|
|
164
|
+
const dbs = [];
|
|
165
|
+
for (const e of safeReaddir(fs, multiDir)) {
|
|
166
|
+
if (e.isFile() && /^MSG\d+\.db$/i.test(e.name)) {
|
|
167
|
+
dbs.push(mkDb(path.join(multiDir, e.name), "message", "私聊/群聊消息", true, fs));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const microMsg = path.join(msgDir, "MicroMsg.db");
|
|
171
|
+
if (safeExists(fs, microMsg)) {
|
|
172
|
+
dbs.push(mkDb(microMsg, "contact", "联系人", true, fs));
|
|
173
|
+
}
|
|
174
|
+
if (dbs.length > 0) {
|
|
175
|
+
layout = layout || "3.x";
|
|
176
|
+
accounts.push({ id: name, root: path.join(root, name), dbs });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return finalize("wechat-pc", accounts, {
|
|
182
|
+
layout,
|
|
183
|
+
encryptedNote:
|
|
184
|
+
"微信本地库为 SQLCipher 加密,需提取数据库密钥后才能解密读取(见 wechat 密钥提取)",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** QQ NT desktop — ~/Documents/Tencent Files/<uin>/nt_qq/nt_db/nt_msg.db */
|
|
189
|
+
function discoverQQ({ fs, path, home, env }) {
|
|
190
|
+
const accounts = [];
|
|
191
|
+
const roots = [
|
|
192
|
+
path.join(home, "Documents", "Tencent Files"),
|
|
193
|
+
env.APPDATA ? path.join(env.APPDATA, "Tencent", "QQ") : null,
|
|
194
|
+
].filter(Boolean);
|
|
195
|
+
for (const root of roots) {
|
|
196
|
+
if (!safeExists(fs, root)) continue;
|
|
197
|
+
for (const name of listSubdirs(fs, path, root)) {
|
|
198
|
+
// per-uin dirs are all-digit; skip nt_qq (global) etc.
|
|
199
|
+
if (!/^\d+$/.test(name)) continue;
|
|
200
|
+
const ntDb = path.join(root, name, "nt_qq", "nt_db", "nt_msg.db");
|
|
201
|
+
if (safeExists(fs, ntDb)) {
|
|
202
|
+
const dbs = [mkDb(ntDb, "message", "私聊/群聊消息", true, fs)];
|
|
203
|
+
const grpInfo = path.join(root, name, "nt_qq", "nt_db", "group_info.db");
|
|
204
|
+
if (safeExists(fs, grpInfo)) dbs.push(mkDb(grpInfo, "group-info", "群信息", true, fs));
|
|
205
|
+
const profile = path.join(root, name, "nt_qq", "nt_db", "profile_info.db");
|
|
206
|
+
if (safeExists(fs, profile)) dbs.push(mkDb(profile, "profile", "好友资料", true, fs));
|
|
207
|
+
accounts.push({ id: name, root: path.join(root, name, "nt_qq"), dbs });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return finalize("qq-pc", accounts, {
|
|
212
|
+
encryptedNote:
|
|
213
|
+
"QQ NT 本地库为 SQLCipher 加密(数字混淆列 + protobuf 消息体),需密钥解密",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** DingTalk / Feishu — proprietary layout, best-effort recursive scan. */
|
|
218
|
+
function discoverGenericIm(appName, roots, { fs, path }) {
|
|
219
|
+
const accounts = [];
|
|
220
|
+
for (const root of roots) {
|
|
221
|
+
if (!safeExists(fs, root)) continue;
|
|
222
|
+
const files = collectDbFiles(fs, path, root, 5, [], 0);
|
|
223
|
+
// Heuristic: message DBs are the larger files whose name hints at chat.
|
|
224
|
+
const dbs = files
|
|
225
|
+
.filter((fp) => !/\b(fts|index|cache|emoji|emoticon|head_image)\b/i.test(fp))
|
|
226
|
+
.map((fp) => {
|
|
227
|
+
const base = path.basename(fp);
|
|
228
|
+
const isMsg = /msg|message|chat|conversation|im_/i.test(base);
|
|
229
|
+
return mkDb(fp, isMsg ? "message" : "other", base, /* encrypted */ false, fs);
|
|
230
|
+
})
|
|
231
|
+
.sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
232
|
+
if (dbs.length > 0) {
|
|
233
|
+
accounts.push({ id: "default", root, dbs });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return finalize(appName, accounts, {
|
|
237
|
+
encryptedNote:
|
|
238
|
+
"桌面本地库为私有结构、可能加密、随版本变化;优先尝试明文直读,必要时先解密",
|
|
239
|
+
bestEffort: true,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function discoverDingTalk({ fs, path, home, env }) {
|
|
244
|
+
const roots = [
|
|
245
|
+
env.APPDATA ? path.join(env.APPDATA, "DingTalk") : null,
|
|
246
|
+
path.join(home, "Documents", "DingTalk"),
|
|
247
|
+
].filter(Boolean);
|
|
248
|
+
return discoverGenericIm("dingtalk-pc", roots, { fs, path });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function discoverFeishu({ fs, path, home, env }) {
|
|
252
|
+
const roots = [
|
|
253
|
+
env.APPDATA ? path.join(env.APPDATA, "Feishu") : null,
|
|
254
|
+
env.APPDATA ? path.join(env.APPDATA, "Lark") : null,
|
|
255
|
+
].filter(Boolean);
|
|
256
|
+
return discoverGenericIm("feishu-pc", roots, { fs, path });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function mkDb(p, purpose, label, encrypted, fs) {
|
|
262
|
+
return {
|
|
263
|
+
path: p,
|
|
264
|
+
purpose, // message | biz-message | contact | sns | favorite | group-info | profile | other
|
|
265
|
+
label,
|
|
266
|
+
encrypted: !!encrypted,
|
|
267
|
+
sizeBytes: safeSize(fs, p),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Common tail: pick the primary message DB (largest message-purpose file
|
|
273
|
+
* across all accounts) and compute the installed flag + summary.
|
|
274
|
+
*/
|
|
275
|
+
function finalize(app, accounts, extra = {}) {
|
|
276
|
+
const allDbs = accounts.flatMap((a) => a.dbs);
|
|
277
|
+
const messageDbs = allDbs
|
|
278
|
+
.filter((d) => d.purpose === "message")
|
|
279
|
+
.sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
280
|
+
const primaryDb = (messageDbs[0] || allDbs[0] || null);
|
|
281
|
+
const anyEncrypted = allDbs.some((d) => d.encrypted);
|
|
282
|
+
return {
|
|
283
|
+
app,
|
|
284
|
+
installed: accounts.length > 0,
|
|
285
|
+
accounts,
|
|
286
|
+
primaryDb: primaryDb ? primaryDb.path : null,
|
|
287
|
+
primaryDbInfo: primaryDb,
|
|
288
|
+
encrypted: anyEncrypted,
|
|
289
|
+
dbCount: allDbs.length,
|
|
290
|
+
layout: extra.layout || null,
|
|
291
|
+
bestEffort: !!extra.bestEffort,
|
|
292
|
+
note: accounts.length > 0 ? extra.encryptedNote || null : "未检测到本地数据(可能未安装或未登录)",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const DISCOVERERS = Object.freeze({
|
|
297
|
+
"wechat-pc": discoverWeChat,
|
|
298
|
+
"qq-pc": discoverQQ,
|
|
299
|
+
"dingtalk-pc": discoverDingTalk,
|
|
300
|
+
"feishu-pc": discoverFeishu,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Discover one App's local databases.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} appKey one of SUPPORTED_APPS
|
|
307
|
+
* @param {object} [deps] { fs, path, platform, home, env } — inject for tests
|
|
308
|
+
* @returns {DiscoveryResult}
|
|
309
|
+
*
|
|
310
|
+
* @typedef {object} DiscoveryResult
|
|
311
|
+
* @property {string} app
|
|
312
|
+
* @property {boolean} installed any account+DB found?
|
|
313
|
+
* @property {Array} accounts [{ id, root, dbs: [{path,purpose,label,encrypted,sizeBytes}] }]
|
|
314
|
+
* @property {string|null} primaryDb best message DB path to point sync at
|
|
315
|
+
* @property {object|null} primaryDbInfo
|
|
316
|
+
* @property {boolean} encrypted any discovered DB is encrypted?
|
|
317
|
+
* @property {number} dbCount
|
|
318
|
+
* @property {string|null} layout version layout hint (wechat: "4.x"/"3.x")
|
|
319
|
+
* @property {boolean} bestEffort heuristic discovery (dingtalk/feishu)?
|
|
320
|
+
* @property {string|null} note
|
|
321
|
+
*/
|
|
322
|
+
function discover(appKey, deps = {}) {
|
|
323
|
+
const fn = DISCOVERERS[appKey];
|
|
324
|
+
if (!fn) {
|
|
325
|
+
return {
|
|
326
|
+
app: appKey,
|
|
327
|
+
installed: false,
|
|
328
|
+
accounts: [],
|
|
329
|
+
primaryDb: null,
|
|
330
|
+
primaryDbInfo: null,
|
|
331
|
+
encrypted: false,
|
|
332
|
+
dbCount: 0,
|
|
333
|
+
layout: null,
|
|
334
|
+
bestEffort: false,
|
|
335
|
+
note: `不支持的 App: ${appKey}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
return fn(makeDeps(deps));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// Discovery must never throw into a readiness probe.
|
|
342
|
+
return {
|
|
343
|
+
app: appKey,
|
|
344
|
+
installed: false,
|
|
345
|
+
accounts: [],
|
|
346
|
+
primaryDb: null,
|
|
347
|
+
primaryDbInfo: null,
|
|
348
|
+
encrypted: false,
|
|
349
|
+
dbCount: 0,
|
|
350
|
+
layout: null,
|
|
351
|
+
bestEffort: false,
|
|
352
|
+
note: `自动发现出错:${err && err.message ? err.message : String(err)}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
discover,
|
|
359
|
+
SUPPORTED_APPS,
|
|
360
|
+
// exported for unit tests
|
|
361
|
+
_internals: { collectDbFiles, finalize, mkDb },
|
|
362
|
+
};
|
|
@@ -61,17 +61,46 @@ class QQPcAdapter {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
_autoDiscover() {
|
|
65
|
+
if (this._discovered !== undefined) return this._discovered;
|
|
66
|
+
try {
|
|
67
|
+
// eslint-disable-next-line global-require
|
|
68
|
+
const { discover } = require("../_pc-local-discovery");
|
|
69
|
+
this._discovered = discover("qq-pc", this._deps.discoveryDeps || {});
|
|
70
|
+
} catch (_e) {
|
|
71
|
+
this._discovered = null;
|
|
72
|
+
}
|
|
73
|
+
return this._discovered;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_resolveDiscoveredDbPath() {
|
|
77
|
+
const disc = this._autoDiscover();
|
|
78
|
+
return disc && disc.installed && disc.primaryDb ? disc.primaryDb : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
async authenticate(ctx = {}) {
|
|
65
82
|
if (ctx && ctx.readinessOnly) {
|
|
66
83
|
if (this._dbPath) return { ok: true, mode: "configured" };
|
|
84
|
+
const disc = this._autoDiscover();
|
|
85
|
+
if (disc && disc.installed) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: "DB_FOUND_NEEDS_KEY",
|
|
89
|
+
message: `已找到本机 QQ 库(${disc.accounts.length} 个账号,主库 ${disc.primaryDb})`,
|
|
90
|
+
discovered: disc,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
67
93
|
return {
|
|
68
94
|
ok: false,
|
|
69
|
-
reason: "
|
|
70
|
-
message:
|
|
71
|
-
"qq-pc: 需提供电脑版 QQ 的 nt_msg.db 路径(加密库需先解密或提供 key)",
|
|
95
|
+
reason: "APP_NOT_INSTALLED",
|
|
96
|
+
message: (disc && disc.note) || "未检测到本机 QQ NT 数据(可能未安装或未登录)",
|
|
72
97
|
};
|
|
73
98
|
}
|
|
74
|
-
const dbPath =
|
|
99
|
+
const dbPath =
|
|
100
|
+
(ctx && ctx.inputPath) ||
|
|
101
|
+
(ctx && ctx.dbPath) ||
|
|
102
|
+
this._dbPath ||
|
|
103
|
+
this._resolveDiscoveredDbPath();
|
|
75
104
|
if (dbPath) {
|
|
76
105
|
try {
|
|
77
106
|
this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
|
|
@@ -84,10 +113,19 @@ class QQPcAdapter {
|
|
|
84
113
|
}
|
|
85
114
|
return { ok: true, mode: "sqlite" };
|
|
86
115
|
}
|
|
116
|
+
const disc = this._autoDiscover();
|
|
117
|
+
if (disc && disc.installed) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: "DB_FOUND_NEEDS_KEY",
|
|
121
|
+
message: `已找到本机 QQ 库(主库 ${disc.primaryDb}),需解密密钥`,
|
|
122
|
+
discovered: disc,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
87
125
|
return {
|
|
88
126
|
ok: false,
|
|
89
|
-
reason: "
|
|
90
|
-
message: "qq-pc.authenticate:
|
|
127
|
+
reason: "APP_NOT_INSTALLED",
|
|
128
|
+
message: "qq-pc.authenticate: 未检测到本机 QQ NT 库,也未提供 dbPath / inputPath",
|
|
91
129
|
};
|
|
92
130
|
}
|
|
93
131
|
|
|
@@ -96,9 +134,10 @@ class QQPcAdapter {
|
|
|
96
134
|
}
|
|
97
135
|
|
|
98
136
|
async *sync(opts = {}) {
|
|
99
|
-
const dbPath =
|
|
137
|
+
const dbPath =
|
|
138
|
+
opts.dbPath || opts.inputPath || this._dbPath || this._resolveDiscoveredDbPath();
|
|
100
139
|
if (!dbPath) {
|
|
101
|
-
throw new Error("qq-pc.sync:
|
|
140
|
+
throw new Error("qq-pc.sync: 未找到本机 QQ NT 库且未提供 opts.dbPath / opts.inputPath");
|
|
102
141
|
}
|
|
103
142
|
if (!this._deps.fs.existsSync(dbPath)) return;
|
|
104
143
|
|
|
@@ -37,8 +37,29 @@ const crypto = require("node:crypto");
|
|
|
37
37
|
const DOUYIN_DB_REMOTE_DIR =
|
|
38
38
|
"/data/data/com.ss.android.ugc.aweme/databases";
|
|
39
39
|
|
|
40
|
+
// Legacy plaintext social-DM IM db (Brignoni 2018 TikTok era, `<19-digit-uid>_im.db`).
|
|
40
41
|
const IM_DB_PATTERN = /^(\d{19})_im\.db$/;
|
|
41
42
|
|
|
43
|
+
// Real-device verification 2026-06-08 (Xiaomi chopin / MIUI 13, Douyin
|
|
44
|
+
// v??-2026 logged in) found CURRENT Douyin no longer ships a plaintext
|
|
45
|
+
// social-DM IM db. Two new on-disk shapes coexist in databases/:
|
|
46
|
+
//
|
|
47
|
+
// encrypted_<uid>_im.db — the social DM store, now SQLCipher-ENCRYPTED
|
|
48
|
+
// (header is NOT `SQLite format 3`). Reading it
|
|
49
|
+
// needs the per-user key, which only the frida
|
|
50
|
+
// key-hook path (Phase 2b, libmsaoaidsec.so
|
|
51
|
+
// anti-debug bypass) can recover — the plaintext
|
|
52
|
+
// C-path here cannot.
|
|
53
|
+
// im_database_<uid> — a Room db, but it is the in-app 豆包/Doubao AI
|
|
54
|
+
// ASSISTANT chat (tables im_message / im_conversation
|
|
55
|
+
// / im_bot), NOT person-to-person social DMs.
|
|
56
|
+
//
|
|
57
|
+
// We classify all three so the handler can emit a precise, actionable error
|
|
58
|
+
// instead of a misleading DOUYIN_NO_IM_DB. See memory
|
|
59
|
+
// [[pdh_douyin_c_path_phase_2a]] / [[pdh_social_cookie_endpoint_drift_2026_05]].
|
|
60
|
+
const ENCRYPTED_IM_DB_PATTERN = /^encrypted_(\d+)_im\.db$/;
|
|
61
|
+
const DOUBAO_IM_DB_PATTERN = /^im_database_(\d{6,})$/;
|
|
62
|
+
|
|
42
63
|
/**
|
|
43
64
|
* List candidate IM db filenames + uid via `adb shell su -c "ls databases/"`.
|
|
44
65
|
*
|
|
@@ -64,15 +85,27 @@ async function listImDbs(adb, serial, opts) {
|
|
|
64
85
|
return { candidates: [], dirMissing: true };
|
|
65
86
|
}
|
|
66
87
|
const candidates = [];
|
|
88
|
+
const encryptedCandidates = [];
|
|
89
|
+
const doubaoCandidates = [];
|
|
67
90
|
for (const line of lines) {
|
|
68
91
|
const fileName = line.trim();
|
|
69
92
|
if (!fileName) continue;
|
|
70
93
|
const m = fileName.match(IM_DB_PATTERN);
|
|
71
94
|
if (m) {
|
|
72
95
|
candidates.push({ uid: m[1], fileName });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const enc = fileName.match(ENCRYPTED_IM_DB_PATTERN);
|
|
99
|
+
if (enc) {
|
|
100
|
+
encryptedCandidates.push({ uid: enc[1], fileName });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const doubao = fileName.match(DOUBAO_IM_DB_PATTERN);
|
|
104
|
+
if (doubao) {
|
|
105
|
+
doubaoCandidates.push({ uid: doubao[1], fileName });
|
|
73
106
|
}
|
|
74
107
|
}
|
|
75
|
-
return { candidates, dirMissing: false };
|
|
108
|
+
return { candidates, encryptedCandidates, doubaoCandidates, dirMissing: false };
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
/**
|
|
@@ -159,9 +192,10 @@ function createDouyinDbExtension(factoryOpts = {}) {
|
|
|
159
192
|
}
|
|
160
193
|
|
|
161
194
|
// Step 1: discover candidate IM dbs.
|
|
162
|
-
const { candidates, dirMissing } =
|
|
163
|
-
|
|
164
|
-
|
|
195
|
+
const { candidates, encryptedCandidates, doubaoCandidates, dirMissing } =
|
|
196
|
+
await listImDbs(ctx.adb, serial, {
|
|
197
|
+
timeoutMs,
|
|
198
|
+
});
|
|
165
199
|
if (dirMissing) {
|
|
166
200
|
throw new Error(
|
|
167
201
|
"DOUYIN_NOT_INSTALLED: " +
|
|
@@ -170,6 +204,32 @@ function createDouyinDbExtension(factoryOpts = {}) {
|
|
|
170
204
|
);
|
|
171
205
|
}
|
|
172
206
|
if (candidates.length === 0) {
|
|
207
|
+
// No legacy plaintext IM db. Distinguish the modern layouts so the UI
|
|
208
|
+
// can tell the user the truth instead of "no db found". Real-device
|
|
209
|
+
// verification 2026-06-08 — see ENCRYPTED_IM_DB_PATTERN comment above.
|
|
210
|
+
if (encryptedCandidates && encryptedCandidates.length > 0) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
"DOUYIN_IM_DB_ENCRYPTED: this Douyin version stores its social DM db as " +
|
|
213
|
+
`\`encrypted_<uid>_im.db\` (SQLCipher) — found ${encryptedCandidates
|
|
214
|
+
.map((c) => c.fileName)
|
|
215
|
+
.join(", ")}. The plaintext C-path can't read it; the per-user ` +
|
|
216
|
+
"key must be recovered via the frida key-hook path (Phase 2b, " +
|
|
217
|
+
"libmsaoaidsec.so anti-debug bypass). Plaintext direct-read is no " +
|
|
218
|
+
"longer possible on current Douyin.",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (doubaoCandidates && doubaoCandidates.length > 0) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"DOUYIN_ONLY_DOUBAO_AI_CHAT: the only readable `im_database_<uid>` db " +
|
|
224
|
+
`(${doubaoCandidates
|
|
225
|
+
.map((c) => c.fileName)
|
|
226
|
+
.join(", ")}) is the in-app 豆包/Doubao AI ASSISTANT chat ` +
|
|
227
|
+
"(tables im_message / im_conversation / im_bot), not person-to-person " +
|
|
228
|
+
"social DMs. Social DMs live in the SQLCipher `encrypted_<uid>_im.db` " +
|
|
229
|
+
"and need the frida key path. (Collecting Doubao AI chat would be a " +
|
|
230
|
+
"separate, net-new adapter.)",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
173
233
|
throw new Error(
|
|
174
234
|
"DOUYIN_NO_IM_DB: no `<19-digit-uid>_im.db` found in databases/. Open the Douyin App + log in once + open any chat thread to materialize the IM database, then retry.",
|
|
175
235
|
);
|
|
@@ -273,6 +333,8 @@ module.exports = {
|
|
|
273
333
|
createDouyinDbExtension,
|
|
274
334
|
DOUYIN_DB_REMOTE_DIR,
|
|
275
335
|
IM_DB_PATTERN,
|
|
336
|
+
ENCRYPTED_IM_DB_PATTERN,
|
|
337
|
+
DOUBAO_IM_DB_PATTERN,
|
|
276
338
|
// Exposed for tests
|
|
277
339
|
_internals: {
|
|
278
340
|
listImDbs,
|
|
@@ -53,6 +53,22 @@ const {
|
|
|
53
53
|
const WEIBO_COOKIES_REMOTE_PATH =
|
|
54
54
|
"/data/data/com.sina.weibo/app_webview/Default/Cookies";
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Glob the WebView profile dir at pull time. Real-device verification
|
|
58
|
+
* (2026-06-08, Xiaomi chopin / MIUI 13 / Weibo logged in) showed current
|
|
59
|
+
* Weibo stores cookies under a SUFFIXED profile dir
|
|
60
|
+
* `app_webview_com.sina.weibo/Default/Cookies`, NOT the standard
|
|
61
|
+
* `app_webview/Default/Cookies` — so the old hardcoded path made the
|
|
62
|
+
* collector throw WEIBO_NOT_INSTALLED even though Weibo was installed and
|
|
63
|
+
* logged in. Chromium names the WebView data dir after the WebView
|
|
64
|
+
* `dataDirectorySuffix` the host app sets; Weibo sets it to its own
|
|
65
|
+
* package name. We glob `app_webview*` and take the first match (Default
|
|
66
|
+
* profile) so both the legacy and suffixed layouts resolve. See memory
|
|
67
|
+
* [[pdh_social_cookie_endpoint_drift_2026_05]].
|
|
68
|
+
*/
|
|
69
|
+
const WEIBO_COOKIES_REMOTE_GLOB =
|
|
70
|
+
"/data/data/com.sina.weibo/app_webview*/Default/Cookies";
|
|
71
|
+
|
|
56
72
|
const WEIBO_COOKIE_HOST_DOMAIN = "m.weibo.cn";
|
|
57
73
|
|
|
58
74
|
/** Minimum required cookie name — without SUB, /api/config returns login=false. */
|
|
@@ -60,21 +76,31 @@ const WEIBO_REQUIRED_COOKIE = "SUB";
|
|
|
60
76
|
|
|
61
77
|
async function pullCookiesViaSu(adb, serial, opts) {
|
|
62
78
|
const adbOpts = { serial, timeoutMs: opts?.timeoutMs || 60_000 };
|
|
79
|
+
// Resolve the actual Cookies path — glob `app_webview*` so the suffixed
|
|
80
|
+
// profile dir (app_webview_com.sina.weibo, observed on real devices) is
|
|
81
|
+
// found as well as the legacy `app_webview`. `ls -d <glob>` prints every
|
|
82
|
+
// match; we take the first (Default profile). When nothing matches the
|
|
83
|
+
// shell prints the unexpanded glob, so we sentinel-guard NOT_FOUND.
|
|
63
84
|
const lsOut = await adb(
|
|
64
85
|
[
|
|
65
86
|
"shell",
|
|
66
87
|
"su",
|
|
67
88
|
"-c",
|
|
68
|
-
`ls ${
|
|
89
|
+
`ls -d ${WEIBO_COOKIES_REMOTE_GLOB} 2>/dev/null | head -n1 || echo NOT_FOUND`,
|
|
69
90
|
],
|
|
70
91
|
adbOpts,
|
|
71
92
|
);
|
|
72
93
|
const lsLine = lsOut.replace(/\r+$/gm, "").trim();
|
|
73
|
-
|
|
94
|
+
const remotePath =
|
|
95
|
+
lsLine && lsLine !== "NOT_FOUND" && !lsLine.includes("*") ? lsLine : null;
|
|
96
|
+
if (!remotePath) {
|
|
74
97
|
throw new Error(
|
|
75
|
-
"WEIBO_NOT_INSTALLED: " +
|
|
76
|
-
|
|
77
|
-
"
|
|
98
|
+
"WEIBO_NOT_INSTALLED: no Cookies DB under " +
|
|
99
|
+
WEIBO_COOKIES_REMOTE_GLOB +
|
|
100
|
+
" (globbed `app_webview*` to cover both the legacy and the suffixed " +
|
|
101
|
+
"`app_webview_com.sina.weibo` profile layouts). Install Weibo App + " +
|
|
102
|
+
"log in once on the phone, then retry. If Weibo is installed but no " +
|
|
103
|
+
"match exists, the WebView dataDirectorySuffix changed again — file a bug.",
|
|
78
104
|
);
|
|
79
105
|
}
|
|
80
106
|
// Probe root.
|
|
@@ -93,7 +119,7 @@ async function pullCookiesViaSu(adb, serial, opts) {
|
|
|
93
119
|
"shell",
|
|
94
120
|
"su",
|
|
95
121
|
"-c",
|
|
96
|
-
`base64 ${
|
|
122
|
+
`base64 ${remotePath} | tr -d '\\n\\r'`,
|
|
97
123
|
],
|
|
98
124
|
{ ...adbOpts, timeoutMs: opts?.timeoutMs || 60_000 },
|
|
99
125
|
);
|
|
@@ -241,6 +267,7 @@ function createWeiboCookiesExtension(factoryOpts = {}) {
|
|
|
241
267
|
module.exports = {
|
|
242
268
|
createWeiboCookiesExtension,
|
|
243
269
|
WEIBO_COOKIES_REMOTE_PATH,
|
|
270
|
+
WEIBO_COOKIES_REMOTE_GLOB,
|
|
244
271
|
WEIBO_COOKIE_HOST_DOMAIN,
|
|
245
272
|
WEIBO_REQUIRED_COOKIE,
|
|
246
273
|
assembleWeiboCookieHeader,
|