@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
package/README.md
CHANGED
|
@@ -4,25 +4,38 @@ Personal Data Hub — UnifiedSchema, validators, batch helpers, SQLCipher
|
|
|
4
4
|
LocalVault, and AdapterRegistry for the "data back to the individual"
|
|
5
5
|
middleware.
|
|
6
6
|
|
|
7
|
-
> **
|
|
8
|
-
>
|
|
9
|
-
>
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
> Phase
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
>
|
|
21
|
-
>
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
>
|
|
7
|
+
> **v0.4.0 (ships with ChainlessChain v5.0.3.99, 2026-06-08).** Phase 0–13
|
|
8
|
+
> of the 13-phase plan in
|
|
9
|
+
> [`docs/design/Personal_Data_Hub_Architecture.md`](../../docs/design/Personal_Data_Hub_Architecture.md)
|
|
10
|
+
> have landed, plus the multi-platform collection layer. The foundation is
|
|
11
|
+
> unchanged: schema + validation + UUID v7 (Phase 0); SQLCipher LocalVault +
|
|
12
|
+
> pluggable key providers + migrations (Phase 1); AdapterRegistry + KG/RAG
|
|
13
|
+
> derivation (Phase 2); the natural-language AnalysisEngine with a hard
|
|
14
|
+
> privacy gate that refuses non-local LLMs unless the caller opts in
|
|
15
|
+
> (Phase 3); and production bridges — **CcLLMAdapter** (wraps cc llm-manager:
|
|
16
|
+
> Ollama / Volcengine / Anthropic / Gemini / DeepSeek), **CcKgSink**, **CcRagSink**
|
|
17
|
+
> — injected at the desktop/CLI entry so this package stays decoupled (Phase 3.5).
|
|
18
|
+
>
|
|
19
|
+
> **51 adapters are now live** (no longer "later phases"): Email IMAP,
|
|
20
|
+
> Alipay bill, 9 AI-chat vendors, WeChat / QQ / Weibo / Bilibili / Douyin /
|
|
21
|
+
> Xiaohongshu / Toutiao / Kuaishou social, Telegram / WhatsApp messaging,
|
|
22
|
+
> Taobao / JD / Meituan / Pinduoduo shopping, Amap / Baidu-map / Tencent-map /
|
|
23
|
+
> Ctrip / 12306 travel, system-data (contacts / calls / sms / location),
|
|
24
|
+
> and the developer-activity set (git / shell / vscode / browser-history /
|
|
25
|
+
> local-files / win-recent).
|
|
26
|
+
>
|
|
27
|
+
> **New in v0.4.0 (v5.0.3.99):** adapter **readiness** — split out from the
|
|
28
|
+
> loose `healthCheck` sync gate into a real ready/needs_setup/unavailable
|
|
29
|
+
> judgment (`registry.readiness()`) with a one-line reason, so "config looks
|
|
30
|
+
> fine but nothing collects" is no longer silent; an `adapter-guide.js`
|
|
31
|
+
> single-source of import steps reused across web-shell / desktop / CLI /
|
|
32
|
+
> Android; new local-direct-read sources (Douyin, WeChat PC, QQ-NT, DingTalk,
|
|
33
|
+
> Feishu, WeRead, Apple Health, NetEase Music); email-bill LLM gap-fill
|
|
34
|
+
> (Phase 5.5); and iOS encrypted-backup decryption (Phase 7.5b).
|
|
35
|
+
>
|
|
36
|
+
> Editing `lib/**` requires bumping the package version + `npm publish` +
|
|
37
|
+
> the Android `USR_VERSION` sentinel, or real devices keep running stale code
|
|
38
|
+
> (see hidden-risk-traps #27/#28).
|
|
26
39
|
|
|
27
40
|
## What's in here
|
|
28
41
|
|
|
@@ -40,6 +53,14 @@ lib/
|
|
|
40
53
|
│ typed put/get, queryEvents, watermarks, audit, key
|
|
41
54
|
│ rotation (WAL-safe), destroy
|
|
42
55
|
├── adapter-spec.js PersonalDataAdapter contract + assertAdapter check
|
|
56
|
+
├── adapter-readiness.js readiness() — ready/needs_setup/unavailable + reason,
|
|
57
|
+
│ split out from the loose healthCheck sync gate
|
|
58
|
+
├── adapter-guide.js category-driven import guides (single source of import
|
|
59
|
+
│ steps reused across web-shell / desktop / CLI / Android)
|
|
60
|
+
├── adapters/ 51 live adapters (email-imap, alipay-bill, ai-chat-history,
|
|
61
|
+
│ wechat / wechat-pc, qq-pc, dingtalk-pc, feishu-pc, weread,
|
|
62
|
+
│ apple-health, netease-music, social-*, shopping-*,
|
|
63
|
+
│ travel-*, system-data, git-activity, vscode, ...)
|
|
43
64
|
├── kg-derive.js UnifiedSchema → KG triples (rdf:type / by / involves /
|
|
44
65
|
│ happened-at / etc.) — engine-agnostic
|
|
45
66
|
├── rag-derive.js UnifiedSchema → RAG (text, metadata) docs for indexing
|
|
@@ -217,7 +238,7 @@ cd packages/personal-data-hub
|
|
|
217
238
|
npm test
|
|
218
239
|
```
|
|
219
240
|
|
|
220
|
-
**
|
|
241
|
+
**2040 tests** across 121 files covering ID generation, all 5 entity validators,
|
|
221
242
|
batch helpers, key providers, vault open/migrations, entity round-trips,
|
|
222
243
|
transactional putBatch with rollback, raw_events archive, queryEvents
|
|
223
244
|
filters + pagination, sync watermarks, audit log, key rotation (WAL-safe),
|
|
@@ -230,11 +251,10 @@ tolerance), and the 1k events <30s ingest perf gate.
|
|
|
230
251
|
|
|
231
252
|
| Concern | Lives in |
|
|
232
253
|
|-----------------------|---------------------------------------------------|
|
|
233
|
-
| Platform KeyProviders (DPAPI/Keychain/Keystore) |
|
|
234
|
-
|
|
|
235
|
-
|
|
|
236
|
-
|
|
|
237
|
-
| AI analysis skills | Phase 11 — `skills/personal-analysis-*/` |
|
|
254
|
+
| Platform KeyProviders (DPAPI/Keychain/Keystore) | desktop-app-vue main-process bridge (the package ships the contract + InMemory/File providers) |
|
|
255
|
+
| Qdrant vector retrieval | wired into the existing RAG engine at the cc entry (BM25 derivation ships here) |
|
|
256
|
+
| AI analysis skills | `skills/personal-analysis-*/` (the 5 built-in analysis skills) |
|
|
257
|
+
| Native SQLCipher build | `better-sqlite3-multiple-ciphers` — host/Electron ABI dual-load handled at the cc entry |
|
|
238
258
|
|
|
239
259
|
## License
|
|
240
260
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const { getAdapterGuide, ADAPTER_OVERRIDES } = require("../lib/adapter-guide");
|
|
6
|
+
|
|
7
|
+
describe("adapter-guide", () => {
|
|
8
|
+
it("wechat-pc guide reflects the 4.0 one-click reality (no manual PyWxDump as primary)", () => {
|
|
9
|
+
const g = getAdapterGuide("wechat-pc", "device");
|
|
10
|
+
// primary method is the automatic one-click, not manual decryption
|
|
11
|
+
const primary = g.methods[0];
|
|
12
|
+
expect(primary.recommended).toBe(true);
|
|
13
|
+
expect(primary.label).toMatch(/一键|自动/);
|
|
14
|
+
expect(primary.steps.join(" ")).toMatch(/一键采集|自动/);
|
|
15
|
+
// summary mentions the full coverage we now capture
|
|
16
|
+
expect(g.summary).toMatch(/公众号/);
|
|
17
|
+
expect(g.summary).toMatch(/朋友圈/);
|
|
18
|
+
expect(g.summary).toMatch(/收藏/);
|
|
19
|
+
// manual 3.x path is still offered as a fallback
|
|
20
|
+
expect(g.methods.some((m) => /3\.x|PyWxDump|手动/.test(m.label + m.steps.join(" ")))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("the 6 social platforms all have a tailored one-click ADB guide", () => {
|
|
24
|
+
for (const name of [
|
|
25
|
+
"social-bilibili",
|
|
26
|
+
"social-weibo",
|
|
27
|
+
"social-douyin",
|
|
28
|
+
"social-xiaohongshu",
|
|
29
|
+
"social-toutiao",
|
|
30
|
+
"social-kuaishou",
|
|
31
|
+
]) {
|
|
32
|
+
expect(ADAPTER_OVERRIDES[name]).toBeTruthy();
|
|
33
|
+
const g = getAdapterGuide(name, "device");
|
|
34
|
+
const primary = g.methods[0];
|
|
35
|
+
expect(primary.recommended).toBe(true);
|
|
36
|
+
// recommended path is root-phone + one-click, not "go log in on the web"
|
|
37
|
+
expect(primary.label + primary.steps.join(" ")).toMatch(/一键|ADB|USB|root/i);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("unknown adapter falls back to a category guide without throwing", () => {
|
|
42
|
+
const g = getAdapterGuide("totally-unknown", "snapshot");
|
|
43
|
+
expect(g.category).toBe("snapshot");
|
|
44
|
+
expect(Array.isArray(g.methods)).toBe(true);
|
|
45
|
+
expect(g.methods.length).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -107,12 +107,17 @@ describe.each([
|
|
|
107
107
|
["DingTalkPcAdapter", DingTalkPcAdapter, "dingtalk"],
|
|
108
108
|
["FeishuPcAdapter", FeishuPcAdapter, "feishu"],
|
|
109
109
|
])("%s (honest best-effort)", (_label, Cls, platform) => {
|
|
110
|
-
it("no-arg construct +
|
|
110
|
+
it("no-arg construct + APP_NOT_INSTALLED when nothing discoverable + legalGate", async () => {
|
|
111
111
|
const a = new Cls();
|
|
112
|
+
a._deps.discoveryDeps = {
|
|
113
|
+
fs: { existsSync: () => false, readdirSync: () => [], statSync: () => ({ size: 0 }), constants: { R_OK: 4 } },
|
|
114
|
+
home: "/no-home",
|
|
115
|
+
env: {},
|
|
116
|
+
};
|
|
112
117
|
expect(a.extractMode).toBe("device-pull");
|
|
113
118
|
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
114
119
|
const r = await a.authenticate({ readinessOnly: true });
|
|
115
|
-
expect(r.reason).toBe("
|
|
120
|
+
expect(r.reason).toBe("APP_NOT_INSTALLED");
|
|
116
121
|
});
|
|
117
122
|
|
|
118
123
|
it("reads messages → valid events, platform tag, raw preserved", async () => {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unit tests for _pc-local-discovery against a synthetic filesystem — no real
|
|
5
|
+
* App needs to be installed. Uses posix path so assertions are host-agnostic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
const path = require("node:path");
|
|
10
|
+
const { discover, SUPPORTED_APPS } = require("../../lib/adapters/_pc-local-discovery");
|
|
11
|
+
|
|
12
|
+
// Build a fake fs from a map of { dirPath: [entryName...] } + a set of file paths.
|
|
13
|
+
function makeFakeFs({ tree = {}, files = {}, sizes = {} } = {}) {
|
|
14
|
+
const norm = (p) => p.replace(/\\/g, "/");
|
|
15
|
+
const fileSet = new Set(Object.keys(files).map(norm));
|
|
16
|
+
const dirSet = new Set(Object.keys(tree).map(norm));
|
|
17
|
+
return {
|
|
18
|
+
existsSync: (p) => fileSet.has(norm(p)) || dirSet.has(norm(p)),
|
|
19
|
+
readdirSync: (p) => {
|
|
20
|
+
const entries = tree[norm(p)] || [];
|
|
21
|
+
return entries.map((e) => ({
|
|
22
|
+
name: e.name,
|
|
23
|
+
isDirectory: () => e.type === "dir",
|
|
24
|
+
isFile: () => e.type === "file",
|
|
25
|
+
}));
|
|
26
|
+
},
|
|
27
|
+
statSync: (p) => ({ size: sizes[norm(p)] || 1 }),
|
|
28
|
+
constants: { R_OK: 4 },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const D = (name) => ({ name, type: "dir" });
|
|
33
|
+
const F = (name) => ({ name, type: "file" });
|
|
34
|
+
|
|
35
|
+
describe("_pc-local-discovery", () => {
|
|
36
|
+
it("supports the expected app keys", () => {
|
|
37
|
+
expect(SUPPORTED_APPS).toEqual(["wechat-pc", "qq-pc", "dingtalk-pc", "feishu-pc"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns installed:false for an unknown app key (never throws)", () => {
|
|
41
|
+
const r = discover("totally-unknown", { fs: makeFakeFs(), home: "/h", env: {}, path: path.posix });
|
|
42
|
+
expect(r.installed).toBe(false);
|
|
43
|
+
expect(r.note).toMatch(/不支持/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns installed:false when nothing is on disk", () => {
|
|
47
|
+
const r = discover("wechat-pc", { fs: makeFakeFs(), home: "/h", env: {}, path: path.posix });
|
|
48
|
+
expect(r.installed).toBe(false);
|
|
49
|
+
expect(r.primaryDb).toBe(null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("discovers a WeChat 4.x account and picks message_0.db as primary", () => {
|
|
53
|
+
const base = "/h/Documents/xwechat_files";
|
|
54
|
+
const acc = `${base}/wxid_demo_42`;
|
|
55
|
+
const fs = makeFakeFs({
|
|
56
|
+
tree: {
|
|
57
|
+
[base]: [D("wxid_demo_42"), D("all_users")],
|
|
58
|
+
[`${acc}/db_storage/message`]: [F("message_0.db"), F("biz_message_0.db"), F("message_fts.db")],
|
|
59
|
+
},
|
|
60
|
+
files: {
|
|
61
|
+
[`${acc}/db_storage`]: 1,
|
|
62
|
+
[`${acc}/db_storage/message/message_0.db`]: 1,
|
|
63
|
+
[`${acc}/db_storage/contact/contact.db`]: 1,
|
|
64
|
+
[`${acc}/db_storage/sns/sns.db`]: 1,
|
|
65
|
+
},
|
|
66
|
+
sizes: {
|
|
67
|
+
[`${acc}/db_storage/message/message_0.db`]: 99,
|
|
68
|
+
[`${acc}/db_storage/message/biz_message_0.db`]: 50,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const r = discover("wechat-pc", { fs, home: "/h", env: {}, path: path.posix });
|
|
72
|
+
expect(r.installed).toBe(true);
|
|
73
|
+
expect(r.layout).toBe("4.x");
|
|
74
|
+
expect(r.encrypted).toBe(true);
|
|
75
|
+
expect(r.accounts).toHaveLength(1);
|
|
76
|
+
expect(r.accounts[0].id).toBe("wxid_demo");
|
|
77
|
+
expect(r.primaryDb).toContain("message_0.db");
|
|
78
|
+
// message_fts.db must NOT be picked up as a message db
|
|
79
|
+
const purposes = r.accounts[0].dbs.map((d) => d.purpose).sort();
|
|
80
|
+
expect(purposes).toContain("message");
|
|
81
|
+
expect(purposes).toContain("contact");
|
|
82
|
+
expect(r.accounts[0].dbs.some((d) => /message_fts/.test(d.path))).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("discovers a WeChat 3.x account (MSG*.db + MicroMsg.db)", () => {
|
|
86
|
+
const base = "/h/Documents/WeChat Files";
|
|
87
|
+
const acc = `${base}/wxid_old`;
|
|
88
|
+
const fs = makeFakeFs({
|
|
89
|
+
tree: {
|
|
90
|
+
[base]: [D("wxid_old"), D("All Users")],
|
|
91
|
+
[`${acc}/Msg/Multi`]: [F("MSG0.db"), F("MSG1.db")],
|
|
92
|
+
},
|
|
93
|
+
files: {
|
|
94
|
+
[`${acc}/Msg/Multi/MSG0.db`]: 1,
|
|
95
|
+
[`${acc}/Msg/Multi/MSG1.db`]: 1,
|
|
96
|
+
[`${acc}/Msg/MicroMsg.db`]: 1,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const r = discover("wechat-pc", { fs, home: "/h", env: {}, path: path.posix });
|
|
100
|
+
expect(r.installed).toBe(true);
|
|
101
|
+
expect(r.layout).toBe("3.x");
|
|
102
|
+
expect(r.accounts[0].dbs.filter((d) => d.purpose === "message")).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("discovers a QQ NT account by numeric uin dir", () => {
|
|
106
|
+
const base = "/h/Documents/Tencent Files";
|
|
107
|
+
const acc = `${base}/896075341`;
|
|
108
|
+
const fs = makeFakeFs({
|
|
109
|
+
tree: { [base]: [D("896075341"), D("nt_qq")] },
|
|
110
|
+
files: {
|
|
111
|
+
[`${acc}/nt_qq/nt_db/nt_msg.db`]: 1,
|
|
112
|
+
[`${acc}/nt_qq/nt_db/group_info.db`]: 1,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const r = discover("qq-pc", { fs, home: "/h", env: {}, path: path.posix });
|
|
116
|
+
expect(r.installed).toBe(true);
|
|
117
|
+
expect(r.accounts[0].id).toBe("896075341");
|
|
118
|
+
expect(r.primaryDb).toContain("nt_msg.db");
|
|
119
|
+
expect(r.encrypted).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("dingtalk best-effort scan finds plaintext db (encrypted:false)", () => {
|
|
123
|
+
const root = "/appdata/DingTalk";
|
|
124
|
+
const fs = makeFakeFs({
|
|
125
|
+
tree: {
|
|
126
|
+
[root]: [D("user1")],
|
|
127
|
+
[`${root}/user1`]: [F("im_message.db"), F("cache.db")],
|
|
128
|
+
},
|
|
129
|
+
files: {
|
|
130
|
+
[`${root}/user1/im_message.db`]: 1,
|
|
131
|
+
[`${root}/user1/cache.db`]: 1,
|
|
132
|
+
},
|
|
133
|
+
sizes: { [`${root}/user1/im_message.db`]: 100 },
|
|
134
|
+
});
|
|
135
|
+
const r = discover("dingtalk-pc", { fs, home: "/h", env: { APPDATA: "/appdata" }, path: path.posix });
|
|
136
|
+
expect(r.installed).toBe(true);
|
|
137
|
+
expect(r.encrypted).toBe(false);
|
|
138
|
+
expect(r.bestEffort).toBe(true);
|
|
139
|
+
expect(r.primaryDb).toContain("im_message.db");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -105,12 +105,17 @@ async function collect(iter) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
describe("QQPcAdapter — readiness + construction", () => {
|
|
108
|
-
it("no-arg construct +
|
|
108
|
+
it("no-arg construct + APP_NOT_INSTALLED when nothing discoverable", async () => {
|
|
109
109
|
const a = new QQPcAdapter();
|
|
110
|
+
a._deps.discoveryDeps = {
|
|
111
|
+
fs: { existsSync: () => false, readdirSync: () => [], statSync: () => ({ size: 0 }), constants: { R_OK: 4 } },
|
|
112
|
+
home: "/no-home",
|
|
113
|
+
env: {},
|
|
114
|
+
};
|
|
110
115
|
expect(a.name).toBe("qq-pc");
|
|
111
116
|
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
112
117
|
const r = await a.authenticate({ readinessOnly: true });
|
|
113
|
-
expect(r.reason).toBe("
|
|
118
|
+
expect(r.reason).toBe("APP_NOT_INSTALLED");
|
|
114
119
|
});
|
|
115
120
|
});
|
|
116
121
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 2a (Douyin C 路径) — cover for the douyin.pull-im-db ADB extension's
|
|
5
|
+
* IM-db discovery + classification.
|
|
6
|
+
*
|
|
7
|
+
* Real-device verification (2026-06-08, Xiaomi chopin / MIUI 13, Douyin
|
|
8
|
+
* logged in) found CURRENT Douyin no longer keeps a plaintext social-DM IM
|
|
9
|
+
* db. The databases/ dir instead holds:
|
|
10
|
+
* - encrypted_<uid>_im.db → SQLCipher social DM (header NOT `SQLite format 3`)
|
|
11
|
+
* - im_database_<uid> → Room db, but it is the in-app 豆包/Doubao AI
|
|
12
|
+
* assistant chat, not social DMs
|
|
13
|
+
* The extension must classify these and emit a precise typed error rather
|
|
14
|
+
* than the misleading DOUYIN_NO_IM_DB.
|
|
15
|
+
*
|
|
16
|
+
* Strategy: scripted fake `ctx.adb` returns a canned `ls` body modeled on
|
|
17
|
+
* the real device listing — no ADB / device needed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
createDouyinDbExtension,
|
|
24
|
+
ENCRYPTED_IM_DB_PATTERN,
|
|
25
|
+
DOUBAO_IM_DB_PATTERN,
|
|
26
|
+
_internals,
|
|
27
|
+
} = require("../../lib/adapters/social-douyin-adb/db-extension");
|
|
28
|
+
|
|
29
|
+
/** Fake ctx: matches the first substring pattern in `responses`. */
|
|
30
|
+
function fakeCtx(responses) {
|
|
31
|
+
const adb = vi.fn(async (args) => {
|
|
32
|
+
const key = args.join(" ");
|
|
33
|
+
for (const [pattern, body] of responses) {
|
|
34
|
+
if (key.includes(pattern)) {
|
|
35
|
+
return typeof body === "function" ? body(args) : body;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`fake adb: no scripted response for: ${key}`);
|
|
39
|
+
});
|
|
40
|
+
return { adb, pickDevice: vi.fn(async () => "FAKE_SERIAL"), parseContentQueryRows: () => [] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Real device listing (trimmed to the IM-relevant files).
|
|
44
|
+
const REAL_DEVICE_LS = [
|
|
45
|
+
"aweme_database_92585448288",
|
|
46
|
+
"encrypted_92585448288_im.db",
|
|
47
|
+
"encrypted_92585448288_im_customer_box.db",
|
|
48
|
+
"im_database_",
|
|
49
|
+
"im_database_6951980119394929011",
|
|
50
|
+
"push_message.db",
|
|
51
|
+
].join("\n");
|
|
52
|
+
|
|
53
|
+
describe("patterns", () => {
|
|
54
|
+
it("ENCRYPTED_IM_DB_PATTERN matches encrypted_<uid>_im.db only", () => {
|
|
55
|
+
expect("encrypted_92585448288_im.db".match(ENCRYPTED_IM_DB_PATTERN)?.[1]).toBe(
|
|
56
|
+
"92585448288",
|
|
57
|
+
);
|
|
58
|
+
// customer_box variant must NOT be mistaken for the DM store
|
|
59
|
+
expect("encrypted_92585448288_im_customer_box.db".match(ENCRYPTED_IM_DB_PATTERN)).toBe(
|
|
60
|
+
null,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("DOUBAO_IM_DB_PATTERN matches im_database_<uid> with a real uid", () => {
|
|
65
|
+
expect("im_database_6951980119394929011".match(DOUBAO_IM_DB_PATTERN)?.[1]).toBe(
|
|
66
|
+
"6951980119394929011",
|
|
67
|
+
);
|
|
68
|
+
// empty-uid `im_database_` must not match (needs ≥6 digits)
|
|
69
|
+
expect("im_database_".match(DOUBAO_IM_DB_PATTERN)).toBe(null);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("listImDbs classification (real-device listing)", () => {
|
|
74
|
+
it("buckets encrypted + doubao, finds no legacy plaintext", async () => {
|
|
75
|
+
const ctx = fakeCtx([["ls ", REAL_DEVICE_LS]]);
|
|
76
|
+
const r = await _internals.listImDbs(ctx.adb, "FAKE_SERIAL", {});
|
|
77
|
+
expect(r.candidates).toEqual([]); // no legacy `<19digit>_im.db`
|
|
78
|
+
expect(r.encryptedCandidates.map((c) => c.fileName)).toEqual([
|
|
79
|
+
"encrypted_92585448288_im.db",
|
|
80
|
+
]);
|
|
81
|
+
expect(r.doubaoCandidates.map((c) => c.fileName)).toEqual([
|
|
82
|
+
"im_database_6951980119394929011",
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("createDouyinDbExtension — precise typed errors", () => {
|
|
88
|
+
it("throws DOUYIN_IM_DB_ENCRYPTED when only the SQLCipher DM db exists", async () => {
|
|
89
|
+
const ctx = fakeCtx([
|
|
90
|
+
["id -u", "0"],
|
|
91
|
+
["ls ", "encrypted_92585448288_im.db\nim_database_6951980119394929011"],
|
|
92
|
+
]);
|
|
93
|
+
const ext = createDouyinDbExtension();
|
|
94
|
+
await expect(ext({}, ctx)).rejects.toThrow(/DOUYIN_IM_DB_ENCRYPTED/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws DOUYIN_ONLY_DOUBAO_AI_CHAT when only the Doubao Room db exists", async () => {
|
|
98
|
+
const ctx = fakeCtx([
|
|
99
|
+
["id -u", "0"],
|
|
100
|
+
["ls ", "im_database_6951980119394929011\npush_message.db"],
|
|
101
|
+
]);
|
|
102
|
+
const ext = createDouyinDbExtension();
|
|
103
|
+
await expect(ext({}, ctx)).rejects.toThrow(/DOUYIN_ONLY_DOUBAO_AI_CHAT/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("still throws DOUYIN_NO_IM_DB when nothing relevant exists", async () => {
|
|
107
|
+
const ctx = fakeCtx([
|
|
108
|
+
["id -u", "0"],
|
|
109
|
+
["ls ", "push_message.db\naweme.db"],
|
|
110
|
+
]);
|
|
111
|
+
const ext = createDouyinDbExtension();
|
|
112
|
+
await expect(ext({}, ctx)).rejects.toThrow(/DOUYIN_NO_IM_DB/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 3a (Weibo C 路径) — cover for the weibo.cookies ADB extension
|
|
5
|
+
* factory, focused on the WebView-profile-dir discovery fix.
|
|
6
|
+
*
|
|
7
|
+
* Real-device verification (2026-06-08, Xiaomi chopin / MIUI 13, Weibo
|
|
8
|
+
* logged in) found current Weibo stores its Chromium cookies under a
|
|
9
|
+
* SUFFIXED profile dir `app_webview_com.sina.weibo/Default/Cookies`, not
|
|
10
|
+
* the legacy `app_webview/Default/Cookies` the collector hardcoded — so the
|
|
11
|
+
* old code threw WEIBO_NOT_INSTALLED on a perfectly logged-in phone. The
|
|
12
|
+
* fix globs `app_webview*` and uses the first match.
|
|
13
|
+
*
|
|
14
|
+
* Strategy mirrors social-bilibili-adb-cookies-extension.test.js: build a
|
|
15
|
+
* real chromium-shape Cookies sqlite, base64 it, and feed it back through a
|
|
16
|
+
* scripted fake `ctx.adb` — no real ADB / device.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
20
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import Database from "better-sqlite3";
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
createWeiboCookiesExtension,
|
|
27
|
+
WEIBO_COOKIES_REMOTE_GLOB,
|
|
28
|
+
WEIBO_COOKIES_REMOTE_PATH,
|
|
29
|
+
} = require("../../lib/adapters/social-weibo-adb/cookies-extension");
|
|
30
|
+
|
|
31
|
+
let tmpDir;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
tmpDir = mkdtempSync(join(tmpdir(), "cc-weibo-ext-test-"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
try {
|
|
39
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
40
|
+
} catch (_e) {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Build a chromium-shape Cookies sqlite + return its base64. */
|
|
46
|
+
function buildCookiesAsBase64(cookies) {
|
|
47
|
+
const dbPath = join(tmpDir, "fixture-cookies");
|
|
48
|
+
const db = new Database(dbPath);
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE TABLE cookies(
|
|
51
|
+
creation_utc INTEGER,
|
|
52
|
+
host_key TEXT,
|
|
53
|
+
name TEXT,
|
|
54
|
+
value TEXT,
|
|
55
|
+
encrypted_value BLOB,
|
|
56
|
+
path TEXT,
|
|
57
|
+
expires_utc INTEGER,
|
|
58
|
+
is_secure INTEGER,
|
|
59
|
+
is_httponly INTEGER,
|
|
60
|
+
is_persistent INTEGER
|
|
61
|
+
);
|
|
62
|
+
`);
|
|
63
|
+
const insert = db.prepare(
|
|
64
|
+
"INSERT INTO cookies(host_key, name, value, path, expires_utc, is_secure, is_httponly, is_persistent) VALUES(?, ?, ?, '/', 0, 0, 1, 1)",
|
|
65
|
+
);
|
|
66
|
+
for (const c of cookies) {
|
|
67
|
+
insert.run(c.hostKey || ".m.weibo.cn", c.name, c.value);
|
|
68
|
+
}
|
|
69
|
+
db.close();
|
|
70
|
+
const buf = readFileSync(dbPath);
|
|
71
|
+
rmSync(dbPath);
|
|
72
|
+
return buf.toString("base64");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Scripted fake ctx. `responses` is an array of [substringPattern, body];
|
|
77
|
+
* the first pattern that the joined args contain wins.
|
|
78
|
+
*/
|
|
79
|
+
function fakeCtx(responses) {
|
|
80
|
+
const adb = vi.fn(async (args) => {
|
|
81
|
+
const key = args.join(" ");
|
|
82
|
+
for (const [pattern, body] of responses) {
|
|
83
|
+
if (key.includes(pattern)) {
|
|
84
|
+
return typeof body === "function" ? body(args) : body;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`fake adb: no scripted response for: ${key}`);
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
ctx: { adb, pickDevice: vi.fn(async () => "FAKE_SERIAL"), parseContentQueryRows: () => [] },
|
|
91
|
+
adb,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SUFFIXED_PATH =
|
|
96
|
+
"/data/data/com.sina.weibo/app_webview_com.sina.weibo/Default/Cookies";
|
|
97
|
+
|
|
98
|
+
describe("constants", () => {
|
|
99
|
+
it("glob covers both legacy and suffixed app_webview layouts", () => {
|
|
100
|
+
expect(WEIBO_COOKIES_REMOTE_GLOB).toBe(
|
|
101
|
+
"/data/data/com.sina.weibo/app_webview*/Default/Cookies",
|
|
102
|
+
);
|
|
103
|
+
// The legacy constant is kept for reference / back-compat callers.
|
|
104
|
+
expect(WEIBO_COOKIES_REMOTE_PATH).toBe(
|
|
105
|
+
"/data/data/com.sina.weibo/app_webview/Default/Cookies",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("createWeiboCookiesExtension — WebView profile dir discovery", () => {
|
|
111
|
+
it("resolves the SUFFIXED app_webview_com.sina.weibo dir + pulls from it", async () => {
|
|
112
|
+
const b64 = buildCookiesAsBase64([{ name: "SUB", value: "sessionTokenXYZ" }]);
|
|
113
|
+
const { ctx, adb } = fakeCtx([
|
|
114
|
+
["ls -d", SUFFIXED_PATH], // glob resolves to suffixed dir
|
|
115
|
+
["id -u", "0"],
|
|
116
|
+
["base64", b64],
|
|
117
|
+
]);
|
|
118
|
+
const ext = createWeiboCookiesExtension();
|
|
119
|
+
const result = await ext({}, ctx);
|
|
120
|
+
|
|
121
|
+
expect(result.cookie).toContain("SUB=sessionTokenXYZ");
|
|
122
|
+
expect(result.diagnostic.hasSub).toBe(true);
|
|
123
|
+
|
|
124
|
+
// The base64 pull MUST target the resolved suffixed path, not the
|
|
125
|
+
// legacy hardcoded one — this is the regression guard for the fix.
|
|
126
|
+
const base64Call = adb.mock.calls.find((c) => c[0].join(" ").includes("base64"));
|
|
127
|
+
expect(base64Call[0].join(" ")).toContain(SUFFIXED_PATH);
|
|
128
|
+
expect(base64Call[0].join(" ")).not.toContain("app_webview/Default");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("still works with the legacy app_webview path (back-compat)", async () => {
|
|
132
|
+
const b64 = buildCookiesAsBase64([{ name: "SUB", value: "legacyTok" }]);
|
|
133
|
+
const { ctx } = fakeCtx([
|
|
134
|
+
["ls -d", WEIBO_COOKIES_REMOTE_PATH],
|
|
135
|
+
["id -u", "0"],
|
|
136
|
+
["base64", b64],
|
|
137
|
+
]);
|
|
138
|
+
const ext = createWeiboCookiesExtension();
|
|
139
|
+
const result = await ext({}, ctx);
|
|
140
|
+
expect(result.cookie).toContain("SUB=legacyTok");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("throws WEIBO_NOT_INSTALLED when glob matches nothing", async () => {
|
|
144
|
+
const { ctx } = fakeCtx([
|
|
145
|
+
["ls -d", "NOT_FOUND"],
|
|
146
|
+
["id -u", "0"],
|
|
147
|
+
]);
|
|
148
|
+
const ext = createWeiboCookiesExtension();
|
|
149
|
+
await expect(ext({}, ctx)).rejects.toThrow(/WEIBO_NOT_INSTALLED/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("throws WEIBO_NOT_INSTALLED when shell echoes the unexpanded glob", async () => {
|
|
153
|
+
// Some shells, when the glob matches nothing AND nullglob is off, echo
|
|
154
|
+
// the literal pattern. The `*`-guard must treat that as not-found.
|
|
155
|
+
const { ctx } = fakeCtx([
|
|
156
|
+
["ls -d", WEIBO_COOKIES_REMOTE_GLOB],
|
|
157
|
+
["id -u", "0"],
|
|
158
|
+
]);
|
|
159
|
+
const ext = createWeiboCookiesExtension();
|
|
160
|
+
await expect(ext({}, ctx)).rejects.toThrow(/WEIBO_NOT_INSTALLED/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("rejects when ctx missing required functions", async () => {
|
|
164
|
+
const ext = createWeiboCookiesExtension();
|
|
165
|
+
await expect(ext({}, {})).rejects.toThrow(/ctx must provide/);
|
|
166
|
+
});
|
|
167
|
+
});
|