@chainlesschain/personal-data-hub 0.4.1 → 0.4.3
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__/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 +43 -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 +54 -19
- 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 +118 -8
- package/lib/adapters/qq-pc/qqnt-sidecar.js +109 -0
- 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
|
@@ -89,14 +89,52 @@ async function collect(iter) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
describe("WeChatPcAdapter — readiness + construction", () => {
|
|
92
|
-
|
|
92
|
+
// Synthetic "nothing installed" filesystem so auto-discovery is
|
|
93
|
+
// deterministic regardless of what's on the host running the tests.
|
|
94
|
+
const EMPTY_FS = {
|
|
95
|
+
existsSync: () => false,
|
|
96
|
+
readdirSync: () => [],
|
|
97
|
+
statSync: () => ({ size: 0 }),
|
|
98
|
+
constants: { R_OK: 4 },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
it("constructs no-arg and reports APP_NOT_INSTALLED when nothing is discoverable", async () => {
|
|
93
102
|
const a = new WeChatPcAdapter();
|
|
103
|
+
a._deps.discoveryDeps = { fs: EMPTY_FS, home: "/no-home", env: {} };
|
|
94
104
|
expect(a.name).toBe("wechat-pc");
|
|
95
105
|
expect(a.extractMode).toBe("device-pull");
|
|
96
106
|
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
97
107
|
const r = await a.authenticate({ readinessOnly: true });
|
|
98
108
|
expect(r.ok).toBe(false);
|
|
99
|
-
expect(r.reason).toBe("
|
|
109
|
+
expect(r.reason).toBe("APP_NOT_INSTALLED");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("auto-discovers an installed WeChat 4.x DB → DB_FOUND_NEEDS_KEY", async () => {
|
|
113
|
+
// Minimal synthetic 4.x layout: ~/Documents/xwechat_files/<wxid>_N/db_storage/message/message_0.db
|
|
114
|
+
const dirs = {
|
|
115
|
+
"/h/Documents/xwechat_files": [{ name: "wxid_abc_1", isDirectory: () => true, isFile: () => false }],
|
|
116
|
+
"/h/Documents/xwechat_files/wxid_abc_1/db_storage/message": [
|
|
117
|
+
{ name: "message_0.db", isDirectory: () => false, isFile: () => true },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
const exist = new Set([
|
|
121
|
+
"/h/Documents/xwechat_files",
|
|
122
|
+
"/h/Documents/xwechat_files/wxid_abc_1/db_storage",
|
|
123
|
+
"/h/Documents/xwechat_files/wxid_abc_1/db_storage/contact/contact.db",
|
|
124
|
+
]);
|
|
125
|
+
const fakeFs = {
|
|
126
|
+
existsSync: (p) => exist.has(p.replace(/\\/g, "/")),
|
|
127
|
+
readdirSync: (p) => dirs[p.replace(/\\/g, "/")] || [],
|
|
128
|
+
statSync: () => ({ size: 1234 }),
|
|
129
|
+
constants: { R_OK: 4 },
|
|
130
|
+
};
|
|
131
|
+
const a = new WeChatPcAdapter();
|
|
132
|
+
a._deps.discoveryDeps = { fs: fakeFs, home: "/h", env: {}, path: require("node:path").posix };
|
|
133
|
+
const r = await a.authenticate({ readinessOnly: true });
|
|
134
|
+
expect(r.ok).toBe(false);
|
|
135
|
+
expect(r.reason).toBe("DB_FOUND_NEEDS_KEY");
|
|
136
|
+
expect(r.discovered.installed).toBe(true);
|
|
137
|
+
expect(r.discovered.primaryDb).toContain("message_0.db");
|
|
100
138
|
});
|
|
101
139
|
|
|
102
140
|
it("readinessOnly with a configured dbPath reports configured (no DB open)", async () => {
|
|
@@ -205,3 +243,123 @@ describe("WeChatPcAdapter — options + edge cases", () => {
|
|
|
205
243
|
);
|
|
206
244
|
});
|
|
207
245
|
});
|
|
246
|
+
|
|
247
|
+
describe("WeChatPcAdapter — WeChat 4.x sidecar path", () => {
|
|
248
|
+
function fakeCollector(result) {
|
|
249
|
+
return async (_opts) => result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it("opts.mode='v4' routes through the injected collector and yields messages", async () => {
|
|
253
|
+
const a = new WeChatPcAdapter({
|
|
254
|
+
v4Collector: fakeCollector({
|
|
255
|
+
account: "wxid_me",
|
|
256
|
+
messageCount: 2,
|
|
257
|
+
dbs: [{ db: "message_0.db", messageCount: 2, hmacFailures: 0 }],
|
|
258
|
+
messages: [
|
|
259
|
+
{
|
|
260
|
+
conversation: "wxid_friend",
|
|
261
|
+
sender: "wxid_friend",
|
|
262
|
+
type: 1,
|
|
263
|
+
createTime: 1700000002,
|
|
264
|
+
text: "hello from 4.0",
|
|
265
|
+
originalId: "wechat-pc:wxid_friend:1001",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
conversation: "39354004187@chatroom",
|
|
269
|
+
sender: "wxid_other",
|
|
270
|
+
type: 1,
|
|
271
|
+
createTime: 1700000003,
|
|
272
|
+
text: "group line",
|
|
273
|
+
originalId: "wechat-pc:39354004187@chatroom:1002",
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
const raws = await collect(a.sync({ mode: "v4" }));
|
|
279
|
+
expect(raws).toHaveLength(2);
|
|
280
|
+
expect(raws[0].originalId).toBe("wechat-pc:wxid_friend:1001");
|
|
281
|
+
expect(raws[0].payload.text).toBe("hello from 4.0");
|
|
282
|
+
expect(raws[0].payload.isGroup).toBe(false);
|
|
283
|
+
// group message: peer is the chatroom, sender preserved
|
|
284
|
+
expect(raws[1].payload.isGroup).toBe(true);
|
|
285
|
+
expect(raws[1].payload.senderWxid).toBe("wxid_other");
|
|
286
|
+
|
|
287
|
+
// normalize reuses the 3.x path → produces a valid message event
|
|
288
|
+
const merged = { events: [], persons: [], places: [], items: [], topics: [] };
|
|
289
|
+
for (const r of raws) {
|
|
290
|
+
const n = a.normalize(r);
|
|
291
|
+
for (const k of Object.keys(merged)) if (Array.isArray(n[k])) merged[k].push(...n[k]);
|
|
292
|
+
}
|
|
293
|
+
const { valid } = partitionBatch(merged);
|
|
294
|
+
expect(valid.events.length).toBe(2);
|
|
295
|
+
const texts = valid.events.map((e) => e.content && e.content.text);
|
|
296
|
+
expect(texts).toContain("hello from 4.0");
|
|
297
|
+
expect(texts).toContain("group line");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("v4 self-sent message marks isSend=1 when sender == account", async () => {
|
|
301
|
+
const a = new WeChatPcAdapter({
|
|
302
|
+
v4Collector: fakeCollector({
|
|
303
|
+
account: "wxid_me",
|
|
304
|
+
messages: [
|
|
305
|
+
{ conversation: "wxid_friend", sender: "wxid_me", type: 1, createTime: 1700000004, text: "mine", originalId: "id-3" },
|
|
306
|
+
],
|
|
307
|
+
}),
|
|
308
|
+
});
|
|
309
|
+
const raws = await collect(a.sync({ mode: "v4" }));
|
|
310
|
+
expect(raws[0].payload.isSend).toBe(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("v4 forwards limit to the sidecar collector (sidecar owns the cap)", async () => {
|
|
314
|
+
const msgs = Array.from({ length: 5 }, (_v, i) => ({
|
|
315
|
+
conversation: "wxid_f", sender: "wxid_f", type: 1, createTime: 1700000000 + i, text: "m" + i, originalId: "id-" + i,
|
|
316
|
+
}));
|
|
317
|
+
let seenLimit = null;
|
|
318
|
+
// Collector that honors the limit, like the real Python sidecar does.
|
|
319
|
+
const collector = async (opts) => {
|
|
320
|
+
seenLimit = opts.limit;
|
|
321
|
+
return { account: "wxid_me", messages: msgs.slice(0, opts.limit || msgs.length), contacts: [] };
|
|
322
|
+
};
|
|
323
|
+
const a = new WeChatPcAdapter({ v4Collector: collector });
|
|
324
|
+
const raws = await collect(a.sync({ mode: "v4", limit: 3 }));
|
|
325
|
+
expect(seenLimit).toBe(3);
|
|
326
|
+
expect(raws.filter((r) => r.kind === "message")).toHaveLength(3);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("v4 yields contacts from contact.db as Person entities", async () => {
|
|
330
|
+
const a = new WeChatPcAdapter({
|
|
331
|
+
v4Collector: fakeCollector({
|
|
332
|
+
account: "wxid_me",
|
|
333
|
+
messages: [
|
|
334
|
+
{ conversation: "wxid_friend", sender: "wxid_friend", type: 1, createTime: 1700000002, text: "hi", originalId: "m-1" },
|
|
335
|
+
],
|
|
336
|
+
contacts: [
|
|
337
|
+
{ wxid: "wxid_friend", nickname: "昵称", remark: "备注名", alias: "alias1", type: 3 },
|
|
338
|
+
{ wxid: "12345@chatroom", nickname: "群", remark: null, alias: null, type: 2 }, // skipped (chatroom)
|
|
339
|
+
],
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
const raws = await collect(a.sync({ mode: "v4" }));
|
|
343
|
+
const contactRaws = raws.filter((r) => r.kind === "contact");
|
|
344
|
+
expect(contactRaws).toHaveLength(1); // chatroom filtered out
|
|
345
|
+
expect(contactRaws[0].payload.wxid).toBe("wxid_friend");
|
|
346
|
+
expect(contactRaws[0].payload.remark).toBe("备注名");
|
|
347
|
+
|
|
348
|
+
// normalize → Person entity
|
|
349
|
+
const n = a.normalize(contactRaws[0]);
|
|
350
|
+
expect(n.persons.length).toBeGreaterThanOrEqual(1);
|
|
351
|
+
expect(n.persons[0].identifiers.wechatId).toBe("wxid_friend");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("v4 can opt out of contacts via include.contact=false", async () => {
|
|
355
|
+
const a = new WeChatPcAdapter({
|
|
356
|
+
v4Collector: fakeCollector({
|
|
357
|
+
account: "wxid_me",
|
|
358
|
+
messages: [],
|
|
359
|
+
contacts: [{ wxid: "wxid_x", nickname: "n", remark: null, alias: null, type: 3 }],
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
const raws = await collect(a.sync({ mode: "v4", include: { contact: false } }));
|
|
363
|
+
expect(raws.filter((r) => r.kind === "contact")).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const { collectWeChatV4, _internals } = require("../../lib/adapters/wechat-pc/v4-sidecar");
|
|
6
|
+
|
|
7
|
+
// Fake SidecarSupervisor: scripted per python exe (command[0]).
|
|
8
|
+
function makeFactory(behaviorByPython, calls) {
|
|
9
|
+
return (command, _cwd) => {
|
|
10
|
+
const py = command[0];
|
|
11
|
+
calls.push(py);
|
|
12
|
+
const behavior = behaviorByPython[py] || { throwOn: "start", error: new Error("ENOENT spawn " + py) };
|
|
13
|
+
return {
|
|
14
|
+
async start() {
|
|
15
|
+
if (behavior.throwOn === "start") throw behavior.error;
|
|
16
|
+
},
|
|
17
|
+
async invoke(_method, _params, _opts) {
|
|
18
|
+
if (behavior.throwOn === "invoke") throw behavior.error;
|
|
19
|
+
return behavior.result;
|
|
20
|
+
},
|
|
21
|
+
async stop() {},
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("collectWeChatV4 — python fallback + error routing", () => {
|
|
27
|
+
it("falls through to the next python when the first lacks cryptography", async () => {
|
|
28
|
+
const calls = [];
|
|
29
|
+
const result = await collectWeChatV4({
|
|
30
|
+
pythonExe: "python", // tried first
|
|
31
|
+
_supervisorFactory: makeFactory(
|
|
32
|
+
{
|
|
33
|
+
python: { throwOn: "invoke", error: new Error("ModuleNotFoundError: No module named 'cryptography'") },
|
|
34
|
+
python3: { throwOn: null, result: { account: "wxid_x", messages: [{ text: "ok" }] } },
|
|
35
|
+
},
|
|
36
|
+
calls,
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
expect(result.account).toBe("wxid_x");
|
|
40
|
+
expect(calls[0]).toBe("python");
|
|
41
|
+
expect(calls).toContain("python3");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("surfaces a WeChat data error immediately (no fallback)", async () => {
|
|
45
|
+
const calls = [];
|
|
46
|
+
await expect(
|
|
47
|
+
collectWeChatV4({
|
|
48
|
+
pythonExe: "python",
|
|
49
|
+
_supervisorFactory: makeFactory(
|
|
50
|
+
{ python: { throwOn: "invoke", error: new Error("KEY_NOT_FOUND: key not found in Weixin.exe memory") } },
|
|
51
|
+
calls,
|
|
52
|
+
),
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow(/KEY_NOT_FOUND/);
|
|
55
|
+
expect(calls).toEqual(["python"]); // did NOT try other pythons
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws SIDECAR_UNAVAILABLE when no python works", async () => {
|
|
59
|
+
const calls = [];
|
|
60
|
+
await expect(
|
|
61
|
+
collectWeChatV4({
|
|
62
|
+
_supervisorFactory: makeFactory({}, calls), // all spawn-fail
|
|
63
|
+
}),
|
|
64
|
+
).rejects.toThrow(/SIDECAR_UNAVAILABLE|could not run/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("pythonCandidates dedupes + honors explicit/env order", () => {
|
|
68
|
+
const list = _internals.pythonCandidates("my-python");
|
|
69
|
+
expect(list[0]).toBe("my-python");
|
|
70
|
+
expect(new Set(list).size).toBe(list.length);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -231,3 +231,62 @@ describe("AdapterRegistry.readiness()", () => {
|
|
|
231
231
|
expect(byName(reports, "messaging-telegram").reason).toBe("DB_NOT_PULLED");
|
|
232
232
|
});
|
|
233
233
|
});
|
|
234
|
+
|
|
235
|
+
describe("AdapterRegistry.readiness() — ADB one-click (social)", () => {
|
|
236
|
+
const oneClick = { oneClickNames: new Set(["social-bilibili"]) };
|
|
237
|
+
|
|
238
|
+
it("device connected → ready via adb-oneclick", async () => {
|
|
239
|
+
const reg = new AdapterRegistry({
|
|
240
|
+
vault: stubVault(),
|
|
241
|
+
adbReadiness: { ...oneClick, probe: async () => ({ deviceConnected: true, serial: "ABC123" }) },
|
|
242
|
+
});
|
|
243
|
+
reg.register(new BilibiliAdapter());
|
|
244
|
+
const [r] = await reg.readiness();
|
|
245
|
+
expect(r.ready).toBe(true);
|
|
246
|
+
expect(r.status).toBe(READINESS_STATUS.READY);
|
|
247
|
+
expect(r.mode).toBe("adb-oneclick");
|
|
248
|
+
expect(r.category).toBe(READINESS_CATEGORY.DEVICE);
|
|
249
|
+
expect(r.message).toMatch(/一键采集/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("no device → ADB_DEVICE_NEEDED (actionable, not the snapshot message)", async () => {
|
|
253
|
+
const reg = new AdapterRegistry({
|
|
254
|
+
vault: stubVault(),
|
|
255
|
+
adbReadiness: { ...oneClick, probe: async () => ({ deviceConnected: false }) },
|
|
256
|
+
});
|
|
257
|
+
reg.register(new BilibiliAdapter());
|
|
258
|
+
const [r] = await reg.readiness();
|
|
259
|
+
expect(r.ready).toBe(false);
|
|
260
|
+
expect(r.reason).toBe("ADB_DEVICE_NEEDED");
|
|
261
|
+
expect(r.category).toBe(READINESS_CATEGORY.DEVICE);
|
|
262
|
+
expect(r.message).toMatch(/root|USB|手机/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("a probe that throws degrades to ADB_DEVICE_NEEDED (never crashes)", async () => {
|
|
266
|
+
const reg = new AdapterRegistry({
|
|
267
|
+
vault: stubVault(),
|
|
268
|
+
adbReadiness: { ...oneClick, probe: async () => { throw new Error("adb missing"); } },
|
|
269
|
+
});
|
|
270
|
+
reg.register(new BilibiliAdapter());
|
|
271
|
+
const [r] = await reg.readiness();
|
|
272
|
+
expect(r.reason).toBe("ADB_DEVICE_NEEDED");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("non-one-click adapter is unaffected by ADB readiness", async () => {
|
|
276
|
+
const reg = new AdapterRegistry({
|
|
277
|
+
vault: stubVault(),
|
|
278
|
+
adbReadiness: { oneClickNames: new Set(["social-bilibili"]), probe: async () => ({ deviceConnected: true }) },
|
|
279
|
+
});
|
|
280
|
+
reg.register(new TelegramAdapter());
|
|
281
|
+
const [r] = await reg.readiness();
|
|
282
|
+
expect(r.name).toBe("messaging-telegram");
|
|
283
|
+
expect(r.reason).toBe("DB_NOT_PULLED"); // unchanged
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("without adbReadiness config, social adapter still reports NO_INPUT", async () => {
|
|
287
|
+
const reg = new AdapterRegistry({ vault: stubVault() });
|
|
288
|
+
reg.register(new BilibiliAdapter());
|
|
289
|
+
const [r] = await reg.readiness();
|
|
290
|
+
expect(r.reason).toBe("NO_INPUT");
|
|
291
|
+
});
|
|
292
|
+
});
|
package/lib/adapter-guide.js
CHANGED
|
@@ -95,6 +95,34 @@ function localImPcGuide(platform) {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Shared guide for the social platforms that have a dedicated one-click ADB
|
|
99
|
+
// sync (B站/微博/小红书/头条/快手) — root 手机 + USB → 界面一键按钮直接采集。
|
|
100
|
+
function socialAdbGuide(platform, dataDesc) {
|
|
101
|
+
return {
|
|
102
|
+
summary: `采集${platform}的${dataDesc}。最快路径:插上已 root 的安卓手机(USB 调试)→ 在中台点该平台的「一键采集」按钮,自动从手机抓登录态并拉取数据入库——无需在网页端手动操作。`,
|
|
103
|
+
methods: [
|
|
104
|
+
{
|
|
105
|
+
label: `方式一:root 手机 + USB 一键采集(推荐)`,
|
|
106
|
+
recommended: true,
|
|
107
|
+
steps: [
|
|
108
|
+
"手机已 root,开启「开发者选项 → USB 调试」,用数据线连接电脑。",
|
|
109
|
+
"确保电脑能看到设备(命令行 `adb devices` 列出你的手机)。",
|
|
110
|
+
`手机上已登录${platform} App。`,
|
|
111
|
+
`在中台点该平台的「一键采集」按钮(或对应的 *AdbSync 操作),自动抓取登录态 + 拉取数据入库。`,
|
|
112
|
+
],
|
|
113
|
+
note: "登录态 / cookie 仅在本地处理,不上传服务器。纯个人使用。",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
label: "方式二:手机 App 内采集快照",
|
|
117
|
+
steps: [
|
|
118
|
+
"在手机 ChainlessChain App 内进入「数据源」,找到该平台点「采集」。",
|
|
119
|
+
"按提示在内置浏览器登录,App 采完生成快照并同步到中台。",
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
function displayName(name) {
|
|
99
127
|
return DISPLAY_NAMES[name] || name;
|
|
100
128
|
}
|
|
@@ -318,46 +346,47 @@ const ADAPTER_OVERRIDES = Object.freeze({
|
|
|
318
346
|
|
|
319
347
|
"qq-pc": {
|
|
320
348
|
summary:
|
|
321
|
-
"采集电脑版 QQ(NT 新版)的聊天记录(来自本地 nt_msg.db
|
|
349
|
+
"采集电脑版 QQ(NT 新版)的聊天记录(来自本地 nt_msg.db)。中台已支持自动解密 + 解析:取一次密钥后,自动解密 SQLCipher 库、解析 c2c/群消息的 protobuf 正文为可读文本(含发送者昵称、群号)。",
|
|
322
350
|
methods: [
|
|
323
351
|
{
|
|
324
|
-
label: "
|
|
352
|
+
label: "方式一:取密钥后一键采集(推荐)",
|
|
325
353
|
recommended: true,
|
|
326
354
|
steps: [
|
|
327
|
-
"
|
|
328
|
-
"
|
|
329
|
-
"
|
|
330
|
-
"
|
|
355
|
+
"在电脑上打开并登录 QQ(NT 新版,数据在 文档\\Tencent Files\\<QQ号>\\nt_qq\\nt_db\\nt_msg.db)。",
|
|
356
|
+
"下载并运行 qq-win-db-key(github.com/QQBackup/qq-win-db-key 的 windows_ntqq_get_key.ps1)。它会全关 QQ → 以调试器启动 QQ → 你登录后自动抓出 16 位密钥(形如 5{sww#,6aq=)8=A@)。",
|
|
357
|
+
"回到中台执行 `cc hub sync-adapter qq-pc --passphrase \"<那串密钥>\"`(或点该行「一键采集」并粘贴密钥)。",
|
|
358
|
+
"中台自动解密 + 解析 c2c_msg_table / group_msg_table → 可读消息入库(私聊 + 群聊,含昵称/群号)。",
|
|
331
359
|
],
|
|
332
|
-
note: "QQ
|
|
360
|
+
note: "QQ 每次重启密钥会变,重采时重新跑 qq-win-db-key 取一次即可。纯个人使用、全程本地;首次会要求法律确认。依赖随中台分发的 Python(含 cryptography)。",
|
|
333
361
|
},
|
|
334
362
|
{
|
|
335
|
-
label: "
|
|
336
|
-
steps: [
|
|
363
|
+
label: "方式二:已解密为明文库则直接导入",
|
|
364
|
+
steps: [
|
|
365
|
+
"若已用工具把 nt_msg.db 解密为明文 SQLite,执行 `cc hub sync-adapter qq-pc --input <明文 nt_msg.db>`。",
|
|
366
|
+
],
|
|
337
367
|
},
|
|
338
368
|
],
|
|
339
369
|
},
|
|
340
370
|
|
|
341
371
|
"wechat-pc": {
|
|
342
372
|
summary:
|
|
343
|
-
"采集电脑版微信的聊天记录 +
|
|
373
|
+
"采集电脑版微信的聊天记录 + 公众号 + 朋友圈 + 收藏 + 联系人。微信 4.0(xwechat_files)已支持全自动一键采集:中台自动发现本机数据库、从运行中的微信进程提取密钥、解密入库——无需手动解密或装第三方工具。",
|
|
344
374
|
methods: [
|
|
345
375
|
{
|
|
346
|
-
label: "
|
|
376
|
+
label: "方式一:一键采集(微信 4.0,推荐,全自动)",
|
|
347
377
|
recommended: true,
|
|
348
378
|
steps: [
|
|
349
|
-
"
|
|
350
|
-
"
|
|
351
|
-
"
|
|
352
|
-
"中台直接读取消息 + 联系人入库(明文 SQLite,无需再解密)。",
|
|
379
|
+
"在这台电脑上打开并登录微信(4.0 版,数据在 文档\\xwechat_files\\)。",
|
|
380
|
+
"回到中台,点 wechat-pc 这一行的「一键采集」(或 `cc hub sync-adapter wechat-pc`)。",
|
|
381
|
+
"中台自动定位各数据库 → 从微信进程内存按库取密钥 → 解密 → 聊天/公众号/朋友圈/收藏/联系人全部入库。",
|
|
353
382
|
],
|
|
354
|
-
note: "
|
|
383
|
+
note: "需要微信保持登录运行(密钥在内存里)。聊天记录含压缩消息与图片/文件/链接/引用等均会解析成可读文本。纯个人使用、全程本地,首次会要求法律确认。依赖随中台分发的 Python(含 cryptography)。",
|
|
355
384
|
},
|
|
356
385
|
{
|
|
357
|
-
label: "
|
|
386
|
+
label: "方式二:旧版微信 3.x / 手动解密",
|
|
358
387
|
steps: [
|
|
359
|
-
"
|
|
360
|
-
"
|
|
388
|
+
"微信 3.x(文档\\WeChat Files\\<wxid>\\Msg\\)用工具(如 PyWxDump)解密 MSG0.db / MicroMsg.db 为明文。",
|
|
389
|
+
"执行 `cc hub sync-adapter wechat-pc --input <解密后的 .db>`(或附 `--key <64位hex>` 让中台尝试直接解密)。",
|
|
361
390
|
],
|
|
362
391
|
},
|
|
363
392
|
],
|
|
@@ -366,6 +395,12 @@ const ADAPTER_OVERRIDES = Object.freeze({
|
|
|
366
395
|
"dingtalk-pc": localImPcGuide("钉钉"),
|
|
367
396
|
"feishu-pc": localImPcGuide("飞书"),
|
|
368
397
|
|
|
398
|
+
"social-bilibili": socialAdbGuide("哔哩哔哩", "观看历史 / 收藏 / 动态 / 关注"),
|
|
399
|
+
"social-weibo": socialAdbGuide("微博", "微博 / 收藏 / 关注"),
|
|
400
|
+
"social-xiaohongshu": socialAdbGuide("小红书", "笔记 / 点赞收藏 / 关注"),
|
|
401
|
+
"social-toutiao": socialAdbGuide("今日头条", "阅读 feed / 收藏 / 搜索历史"),
|
|
402
|
+
"social-kuaishou": socialAdbGuide("快手", "作品 / 推荐 / 个人主页"),
|
|
403
|
+
|
|
369
404
|
"social-douyin": {
|
|
370
405
|
summary:
|
|
371
406
|
"采集抖音私信 + 联系人(来自 App 本地明文数据库 <uid>_im.db)。明文 SQLite、无加密、无 X-Bogus 签名——本地直读是最可靠的方式。",
|
package/lib/adapter-readiness.js
CHANGED
|
@@ -86,6 +86,29 @@ const REASONS = Object.freeze({
|
|
|
86
86
|
actionHint: "通过 ADB / 本地 DB 解密导出数据库后再同步",
|
|
87
87
|
appendDetail: true,
|
|
88
88
|
},
|
|
89
|
+
// 自动发现:已在本机找到 App 的加密数据库,只差解密密钥即可一键采集。
|
|
90
|
+
DB_FOUND_NEEDS_KEY: {
|
|
91
|
+
status: READINESS_STATUS.NEEDS_SETUP,
|
|
92
|
+
category: READINESS_CATEGORY.DEVICE,
|
|
93
|
+
message: "已自动找到本机数据库(已加密),仅需解密密钥即可一键采集",
|
|
94
|
+
actionHint: "提取该 App 的数据库密钥后点「一键采集」(密钥可从运行中的 App 提取)",
|
|
95
|
+
appendDetail: true,
|
|
96
|
+
},
|
|
97
|
+
// ADB 一键平台:后端支持 root 手机 USB 一键采集,但当前未检测到设备。
|
|
98
|
+
ADB_DEVICE_NEEDED: {
|
|
99
|
+
status: READINESS_STATUS.NEEDS_SETUP,
|
|
100
|
+
category: READINESS_CATEGORY.DEVICE,
|
|
101
|
+
message: "可一键采集:请插上已 root 的安卓手机并开启 USB 调试(adb 可见后点「一键采集」)",
|
|
102
|
+
actionHint: "连接手机后刷新,即可一键拉取",
|
|
103
|
+
},
|
|
104
|
+
// 自动发现:未检测到 App 的本机数据(未安装 / 未登录 / 非默认目录)。
|
|
105
|
+
APP_NOT_INSTALLED: {
|
|
106
|
+
status: READINESS_STATUS.UNAVAILABLE,
|
|
107
|
+
category: READINESS_CATEGORY.DEVICE,
|
|
108
|
+
message: "未检测到该 App 的本机数据(可能未安装、未登录或装在非默认目录)",
|
|
109
|
+
actionHint: "在本机安装并登录该 App 后重试,或改用手机端采集",
|
|
110
|
+
appendDetail: true,
|
|
111
|
+
},
|
|
89
112
|
NO_KEY_PROVIDER: {
|
|
90
113
|
status: READINESS_STATUS.NEEDS_SETUP,
|
|
91
114
|
category: READINESS_CATEGORY.DEVICE,
|
|
@@ -58,12 +58,41 @@ function createLocalImPcAdapter(cfg) {
|
|
|
58
58
|
this._deps = { fs, dbDriverFactory: opts.dbDriverFactory || null };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
_autoDiscover() {
|
|
62
|
+
if (this._discovered !== undefined) return this._discovered;
|
|
63
|
+
try {
|
|
64
|
+
// eslint-disable-next-line global-require
|
|
65
|
+
const { discover } = require("./_pc-local-discovery");
|
|
66
|
+
this._discovered = discover(NAME, this._deps.discoveryDeps || {});
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
this._discovered = null;
|
|
69
|
+
}
|
|
70
|
+
return this._discovered;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_resolveDiscoveredDbPath() {
|
|
74
|
+
const disc = this._autoDiscover();
|
|
75
|
+
return disc && disc.installed && disc.primaryDb ? disc.primaryDb : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
async authenticate(ctx = {}) {
|
|
62
79
|
if (ctx && ctx.readinessOnly) {
|
|
63
80
|
if (this._dbPath) return { ok: true, mode: "configured" };
|
|
64
|
-
|
|
81
|
+
const disc = this._autoDiscover();
|
|
82
|
+
if (disc && disc.installed) {
|
|
83
|
+
// best-effort plaintext DB → one-click ready; encrypted → needs key
|
|
84
|
+
if (!disc.encrypted) return { ok: true, mode: "auto-discovered" };
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: "DB_FOUND_NEEDS_KEY",
|
|
88
|
+
message: `已找到本机 ${PLATFORM} 库(主库 ${disc.primaryDb}),可能需解密`,
|
|
89
|
+
discovered: disc,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, reason: "APP_NOT_INSTALLED", message: (disc && disc.note) || cfg.needHint };
|
|
65
93
|
}
|
|
66
|
-
const dbPath =
|
|
94
|
+
const dbPath =
|
|
95
|
+
(ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._dbPath || this._resolveDiscoveredDbPath();
|
|
67
96
|
if (dbPath) {
|
|
68
97
|
try {
|
|
69
98
|
this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
|
|
@@ -72,7 +101,7 @@ function createLocalImPcAdapter(cfg) {
|
|
|
72
101
|
}
|
|
73
102
|
return { ok: true, mode: "sqlite" };
|
|
74
103
|
}
|
|
75
|
-
return { ok: false, reason: "
|
|
104
|
+
return { ok: false, reason: "APP_NOT_INSTALLED", message: `${NAME}.authenticate: 未检测到本机 ${PLATFORM} 库,也未提供 dbPath / inputPath` };
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
async healthCheck() {
|
|
@@ -80,8 +109,8 @@ function createLocalImPcAdapter(cfg) {
|
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
async *sync(opts = {}) {
|
|
83
|
-
const dbPath = opts.dbPath || opts.inputPath || this._dbPath;
|
|
84
|
-
if (!dbPath) throw new Error(`${NAME}.sync:
|
|
112
|
+
const dbPath = opts.dbPath || opts.inputPath || this._dbPath || this._resolveDiscoveredDbPath();
|
|
113
|
+
if (!dbPath) throw new Error(`${NAME}.sync: 未找到本机 ${PLATFORM} 库且未提供 opts.dbPath / opts.inputPath`);
|
|
85
114
|
if (!this._deps.fs.existsSync(dbPath)) return;
|
|
86
115
|
|
|
87
116
|
// eslint-disable-next-line global-require
|