@chainlesschain/personal-data-hub 0.3.9 → 0.4.1

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.
Files changed (58) hide show
  1. package/README.md +45 -25
  2. package/__tests__/adapters/apple-health.test.js +95 -0
  3. package/__tests__/adapters/email-templates.test.js +123 -0
  4. package/__tests__/adapters/family-23-collectors-scaffold.test.js +178 -0
  5. package/__tests__/adapters/game-genshin-scaffold.test.js +107 -0
  6. package/__tests__/adapters/git-activity.test.js +7 -1
  7. package/__tests__/adapters/local-im-pc.test.js +149 -0
  8. package/__tests__/adapters/netease-music.test.js +74 -0
  9. package/__tests__/adapters/qq-pc-direct-read.test.js +186 -0
  10. package/__tests__/adapters/system-data-adapter.test.js +4 -1
  11. package/__tests__/adapters/wechat-pc-direct-read.test.js +207 -0
  12. package/__tests__/adapters/weread.test.js +123 -0
  13. package/__tests__/analysis.test.js +120 -15
  14. package/__tests__/mobile-extractor-encrypted.test.js +460 -0
  15. package/__tests__/prompt-builder.test.js +25 -0
  16. package/__tests__/registry-readiness.test.js +233 -0
  17. package/__tests__/social-douyin-im-direct-read.test.js +311 -0
  18. package/__tests__/social-douyin-snapshot.test.js +5 -2
  19. package/__tests__/vault.test.js +99 -0
  20. package/lib/adapter-guide.js +520 -0
  21. package/lib/adapter-readiness.js +257 -0
  22. package/lib/adapters/_local-im-db-reader.js +218 -0
  23. package/lib/adapters/_local-im-pc-adapter.js +162 -0
  24. package/lib/adapters/apple-health/index.js +329 -0
  25. package/lib/adapters/dingtalk-pc/index.js +29 -0
  26. package/lib/adapters/edu-huawei-learning/api-client.js +47 -0
  27. package/lib/adapters/edu-huawei-learning/index.js +255 -0
  28. package/lib/adapters/edu-zuoyebang/api-client.js +48 -0
  29. package/lib/adapters/edu-zuoyebang/index.js +259 -0
  30. package/lib/adapters/email-imap/email-adapter.js +16 -0
  31. package/lib/adapters/email-imap/templates/bill.js +174 -18
  32. package/lib/adapters/feishu-pc/index.js +29 -0
  33. package/lib/adapters/finance-alipay/api-client.js +48 -0
  34. package/lib/adapters/finance-alipay/index.js +257 -0
  35. package/lib/adapters/game-genshin/api-client.js +59 -0
  36. package/lib/adapters/game-genshin/index.js +274 -0
  37. package/lib/adapters/game-honor-of-kings/api-client.js +54 -0
  38. package/lib/adapters/game-honor-of-kings/index.js +259 -0
  39. package/lib/adapters/netease-music/index.js +227 -0
  40. package/lib/adapters/qq-pc/index.js +200 -0
  41. package/lib/adapters/qq-pc/nt-db-reader.js +210 -0
  42. package/lib/adapters/social-douyin/index.js +194 -1
  43. package/lib/adapters/wechat/wechat-adapter.js +7 -1
  44. package/lib/adapters/wechat-pc/index.js +335 -0
  45. package/lib/adapters/wechat-pc/pc-db-reader.js +327 -0
  46. package/lib/adapters/weread/api-client.js +128 -0
  47. package/lib/adapters/weread/index.js +337 -0
  48. package/lib/analysis.js +65 -0
  49. package/lib/index.js +39 -0
  50. package/lib/mobile-extractor/bplist.js +233 -0
  51. package/lib/mobile-extractor/ios-backup-crypto.js +315 -0
  52. package/lib/mobile-extractor/ios.js +131 -16
  53. package/lib/prompt-builder.js +11 -1
  54. package/lib/registry.js +170 -0
  55. package/lib/vault.js +105 -0
  56. package/package.json +1 -1
  57. package/scripts/run-native-tests-sandbox.sh +2 -0
  58. package/vitest.config.js +79 -1
@@ -0,0 +1,259 @@
1
+ /**
2
+ * FAMILY-23 v0.1 — 王者荣耀 (Honor of Kings) adapter, snapshot mode.
3
+ *
4
+ * 家庭守护 telemetry:家长看孩子玩什么游戏/玩多久。v0.1 cookie-scrape 占位 —
5
+ * [HonorOfKingsApiClient.extractUid] 抽 openid/uin;snapshot 模式消费手机端 collector
6
+ * 快照 (profile + play-session)。战绩 HTTP fetcher(营地接口 + 腾讯签名)留 v0.2,
7
+ * 故无 inputPath 时 sync 抛 NO_INPUT(同 social-kuaishou snapshot 先例)。
8
+ *
9
+ * Snapshot schema (v1):
10
+ * { schemaVersion:1, snapshottedAt, account:{uid,displayName}, events:[
11
+ * { kind:"profile", id, capturedAt, uid, nickname, level, rank, avatarUrl },
12
+ * { kind:"play", id, capturedAt, durationMs, mode, startAt } ] }
13
+ *
14
+ * Sensitivity: "medium"。
15
+ */
16
+ "use strict";
17
+
18
+ const fs = require("node:fs");
19
+ const { newId } = require("../../ids");
20
+ const {
21
+ ENTITY_TYPES,
22
+ PERSON_SUBTYPES,
23
+ EVENT_SUBTYPES,
24
+ CAPTURED_BY,
25
+ } = require("../../constants");
26
+ const { HonorOfKingsApiClient } = require("./api-client");
27
+
28
+ const NAME = "game-honor-of-kings";
29
+ const VERSION = "0.1.0";
30
+ const SNAPSHOT_SCHEMA_VERSION = 1;
31
+ const KIND_PROFILE = "profile";
32
+ const KIND_PLAY = "play";
33
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_PROFILE, KIND_PLAY]);
34
+
35
+ function stableOriginalId(kind, id) {
36
+ const safe =
37
+ (typeof id === "string" && id.length > 0 && id) ||
38
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
39
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
40
+ return `hok:${kind}:${safe}`;
41
+ }
42
+
43
+ function parseTime(v) {
44
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
45
+ if (typeof v === "string") {
46
+ if (/^\d+$/.test(v)) {
47
+ const n = parseInt(v, 10);
48
+ return n > 1e12 ? n : n * 1000;
49
+ }
50
+ const t = Date.parse(v);
51
+ return Number.isFinite(t) ? t : null;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ class HonorOfKingsAdapter {
57
+ constructor(opts = {}) {
58
+ this.account = opts.account || null;
59
+ this.name = NAME;
60
+ this.version = VERSION;
61
+ this.capabilities = [
62
+ "sync:snapshot",
63
+ "parse:hok-profile",
64
+ "parse:hok-play-session",
65
+ ];
66
+ this.extractMode = "web-api";
67
+ this.rateLimits = {};
68
+ this.dataDisclosure = {
69
+ fields: [
70
+ "hok:profile (uid / nickname / level / rank / avatar)",
71
+ "hok:play_session (start / duration / mode)",
72
+ ],
73
+ sensitivity: "medium",
74
+ legalGate: false,
75
+ defaultInclude: { profile: true, play: true },
76
+ };
77
+ this.apiClient = new HonorOfKingsApiClient();
78
+ this._deps = { fs };
79
+ }
80
+
81
+ async authenticate(ctx = {}) {
82
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
83
+ try {
84
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
85
+ } catch (err) {
86
+ return {
87
+ ok: false,
88
+ reason: "INPUT_PATH_UNREADABLE",
89
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
90
+ };
91
+ }
92
+ return { ok: true, mode: "snapshot-file" };
93
+ }
94
+ return {
95
+ ok: false,
96
+ reason: "NO_INPUT",
97
+ message:
98
+ "game-honor-of-kings.authenticate: v0.1 needs opts.inputPath (snapshot mode); live HTTP fetcher 待 v0.2",
99
+ };
100
+ }
101
+
102
+ async healthCheck() {
103
+ return { ok: true, lastChecked: Date.now() };
104
+ }
105
+
106
+ async *sync(opts = {}) {
107
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
108
+ yield* this._syncViaSnapshot(opts);
109
+ return;
110
+ }
111
+ throw new Error(
112
+ "game-honor-of-kings.sync: v0.1 needs opts.inputPath (snapshot mode); 营地战绩 HTTP fetcher 待 v0.2",
113
+ );
114
+ }
115
+
116
+ async *_syncViaSnapshot(opts) {
117
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
118
+ const snapshot = JSON.parse(raw);
119
+ if (
120
+ !snapshot ||
121
+ typeof snapshot !== "object" ||
122
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
123
+ ) {
124
+ throw new Error(
125
+ `game-honor-of-kings.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
126
+ );
127
+ }
128
+ const fallbackCapturedAt =
129
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
130
+ ? Math.floor(snapshot.snapshottedAt)
131
+ : Date.now();
132
+ const account =
133
+ snapshot.account && typeof snapshot.account === "object"
134
+ ? snapshot.account
135
+ : null;
136
+ const include = opts.include || {};
137
+ const limit =
138
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
139
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
140
+ let emitted = 0;
141
+ for (const ev of events) {
142
+ if (emitted >= limit) return;
143
+ if (!ev || typeof ev !== "object") continue;
144
+ const kind = ev.kind;
145
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
146
+ if (include[kind] === false) continue;
147
+ const capturedAt = parseTime(ev.capturedAt) || fallbackCapturedAt;
148
+ const id =
149
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
150
+ ev.uid ||
151
+ null;
152
+ yield {
153
+ adapter: NAME,
154
+ kind,
155
+ originalId: stableOriginalId(kind, id),
156
+ capturedAt,
157
+ payload: { ...ev, account },
158
+ };
159
+ emitted += 1;
160
+ }
161
+ }
162
+
163
+ normalize(raw) {
164
+ if (!raw || !raw.payload) {
165
+ throw new Error("HonorOfKingsAdapter.normalize: payload missing");
166
+ }
167
+ const ingestedAt = Date.now();
168
+ const kind = raw.kind || raw.payload.kind;
169
+ const p = raw.payload;
170
+ if (kind === KIND_PROFILE) return normalizeProfile(p, raw, ingestedAt);
171
+ if (kind === KIND_PLAY) return normalizePlay(p, raw, ingestedAt);
172
+ throw new Error(`HonorOfKingsAdapter.normalize: unknown kind ${kind}`);
173
+ }
174
+ }
175
+
176
+ function buildSource(raw, occurredAt) {
177
+ return {
178
+ adapter: NAME,
179
+ adapterVersion: VERSION,
180
+ originalId: raw.originalId,
181
+ capturedAt: raw.capturedAt || occurredAt,
182
+ capturedBy: CAPTURED_BY.API,
183
+ };
184
+ }
185
+
186
+ function normalizeProfile(p, raw, ingestedAt) {
187
+ const uid = p.uid || (p.account && p.account.uid) || null;
188
+ const nickname =
189
+ p.nickname || (p.account && p.account.displayName) || "(unnamed)";
190
+ const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
191
+ const identifiers = {};
192
+ if (uid) identifiers["hok-uid"] = [String(uid)];
193
+ return {
194
+ events: [],
195
+ persons: [
196
+ {
197
+ id: uid ? `person-hok-${uid}` : `person-hok-self-${newId()}`,
198
+ type: ENTITY_TYPES.PERSON,
199
+ subtype: PERSON_SUBTYPES.SELF,
200
+ names: [nickname],
201
+ ingestedAt,
202
+ source: buildSource(raw, occurredAt),
203
+ identifiers,
204
+ extra: {
205
+ platform: "honor-of-kings",
206
+ level: p.level != null ? p.level : null,
207
+ rank: p.rank || null,
208
+ avatarUrl: p.avatarUrl || null,
209
+ snapshottedAt: occurredAt,
210
+ },
211
+ },
212
+ ],
213
+ places: [],
214
+ items: [],
215
+ topics: [],
216
+ };
217
+ }
218
+
219
+ function normalizePlay(p, raw, ingestedAt) {
220
+ const occurredAt =
221
+ parseTime(p.startAt) ||
222
+ parseTime(p.capturedAt) ||
223
+ raw.capturedAt ||
224
+ ingestedAt;
225
+ return {
226
+ events: [
227
+ {
228
+ id: newId(),
229
+ type: ENTITY_TYPES.EVENT,
230
+ subtype: EVENT_SUBTYPES.MEDIA,
231
+ occurredAt,
232
+ actor: "person-self",
233
+ content: { title: "王者荣耀 游戏时长" },
234
+ ingestedAt,
235
+ source: buildSource(raw, occurredAt),
236
+ extra: {
237
+ platform: "honor-of-kings",
238
+ kind: "play",
239
+ durationMs: Number.isFinite(p.durationMs) ? p.durationMs : 0,
240
+ mode: p.mode || null,
241
+ },
242
+ },
243
+ ],
244
+ persons: [],
245
+ places: [],
246
+ items: [],
247
+ topics: [],
248
+ };
249
+ }
250
+
251
+ module.exports = {
252
+ HonorOfKingsAdapter,
253
+ NAME,
254
+ VERSION,
255
+ SNAPSHOT_SCHEMA_VERSION,
256
+ VALID_SNAPSHOT_KINDS,
257
+ KIND_PROFILE,
258
+ KIND_PLAY,
259
+ };
@@ -0,0 +1,227 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * 网易云音乐 (NetEase Cloud Music) adapter — snapshot mode.
5
+ *
6
+ * Mirrors the social snapshot adapters (bilibili/douyin): a device-side
7
+ * collector (Android in-app, or a desktop helper hitting the cookie web API
8
+ * `/user/record` `/user/playlist`) writes a snapshot JSON; this adapter
9
+ * ingests it. Schema is OUR contract, so normalize is fully testable and the
10
+ * vault path is reliable regardless of how the bytes were captured.
11
+ *
12
+ * Snapshot schema (schemaVersion 1):
13
+ * {
14
+ * schemaVersion: 1, snapshottedAt: <ms>,
15
+ * account: { uid, nickname },
16
+ * events: [
17
+ * { kind: "play", id, capturedAt, song, artist, album, songId, playCount },
18
+ * { kind: "favorite", id, capturedAt, song, artist, album, songId },
19
+ * { kind: "playlist", id, capturedAt, name, playlistId, trackCount, creator }
20
+ * ]
21
+ * }
22
+ *
23
+ * play → EVENT(media, "听了 <song>") + ITEM(song)
24
+ * favorite → EVENT(like) + ITEM(song)
25
+ * playlist → TOPIC(歌单)
26
+ */
27
+
28
+ const fs = require("node:fs");
29
+ const { newId } = require("../../ids");
30
+ const {
31
+ ENTITY_TYPES,
32
+ EVENT_SUBTYPES,
33
+ ITEM_SUBTYPES,
34
+ CAPTURED_BY,
35
+ } = require("../../constants");
36
+
37
+ const NAME = "netease-music";
38
+ const VERSION = "0.1.0";
39
+ const SNAPSHOT_SCHEMA_VERSION = 1;
40
+
41
+ const KIND_PLAY = "play";
42
+ const KIND_FAVORITE = "favorite";
43
+ const KIND_PLAYLIST = "playlist";
44
+ const VALID_KINDS = Object.freeze([KIND_PLAY, KIND_FAVORITE, KIND_PLAYLIST]);
45
+
46
+ function parseTime(v) {
47
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
48
+ if (typeof v === "string" && /^\d+$/.test(v)) {
49
+ const n = parseInt(v, 10);
50
+ return n > 1e12 ? n : n * 1000;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function stableOriginalId(kind, id) {
56
+ const safe =
57
+ (typeof id === "string" && id.length > 0 && id) ||
58
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
59
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
60
+ return `netease-music:${kind}:${safe}`;
61
+ }
62
+
63
+ class NeteaseMusicAdapter {
64
+ constructor(opts = {}) {
65
+ this._dataPath = opts.inputPath || null;
66
+ this.name = NAME;
67
+ this.version = VERSION;
68
+ this.capabilities = [
69
+ "sync:snapshot",
70
+ "parse:netease-play",
71
+ "parse:netease-favorite",
72
+ "parse:netease-playlist",
73
+ ];
74
+ this.extractMode = "web-api";
75
+ this.rateLimits = {};
76
+ this.dataDisclosure = {
77
+ fields: [
78
+ "netease:play (歌名 / 歌手 / 专辑 / 播放次数)",
79
+ "netease:favorite (收藏的歌)",
80
+ "netease:playlist (歌单名 / 曲目数)",
81
+ ],
82
+ sensitivity: "low",
83
+ legalGate: false,
84
+ };
85
+ this._deps = { fs };
86
+ }
87
+
88
+ async authenticate(ctx = {}) {
89
+ if (ctx && ctx.readinessOnly) {
90
+ return {
91
+ ok: false,
92
+ reason: "NO_INPUT",
93
+ message: "netease-music: 需手机 App 内采集听歌记录/歌单快照后回传",
94
+ };
95
+ }
96
+ const inputPath = (ctx && ctx.inputPath) || this._dataPath;
97
+ if (inputPath) {
98
+ try {
99
+ this._deps.fs.accessSync(inputPath, this._deps.fs.constants.R_OK);
100
+ } catch (err) {
101
+ return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `snapshot not readable: ${err.message}` };
102
+ }
103
+ return { ok: true, mode: "snapshot-file" };
104
+ }
105
+ return { ok: false, reason: "NO_INPUT", message: "netease-music.authenticate: needs opts.inputPath" };
106
+ }
107
+
108
+ async healthCheck() {
109
+ return { ok: true, lastChecked: Date.now() };
110
+ }
111
+
112
+ async *sync(opts = {}) {
113
+ const inputPath = opts.inputPath || this._dataPath;
114
+ if (!inputPath) throw new Error("netease-music.sync: needs opts.inputPath (snapshot JSON)");
115
+ if (!this._deps.fs.existsSync(inputPath)) return;
116
+ const snapshot = JSON.parse(this._deps.fs.readFileSync(inputPath, "utf-8"));
117
+ if (!snapshot || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
118
+ throw new Error(
119
+ `netease-music.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
120
+ );
121
+ }
122
+ const fallback =
123
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
124
+ ? Math.floor(snapshot.snapshottedAt)
125
+ : Date.now();
126
+ const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
127
+ const include = opts.include || {};
128
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
129
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
130
+ let emitted = 0;
131
+ for (const ev of events) {
132
+ if (emitted >= limit) return;
133
+ if (!ev || typeof ev !== "object" || !VALID_KINDS.includes(ev.kind)) continue;
134
+ if (include[ev.kind] === false) continue;
135
+ const id = (typeof ev.id === "string" && ev.id) || ev.songId || ev.playlistId || null;
136
+ yield {
137
+ adapter: NAME,
138
+ kind: ev.kind,
139
+ originalId: stableOriginalId(ev.kind, id),
140
+ capturedAt: parseTime(ev.capturedAt) || fallback,
141
+ payload: { ...ev, account },
142
+ };
143
+ emitted += 1;
144
+ }
145
+ }
146
+
147
+ normalize(raw) {
148
+ if (!raw || !raw.payload) throw new Error("NeteaseMusicAdapter.normalize: payload missing");
149
+ const kind = raw.kind || raw.payload.kind;
150
+ const ingestedAt = Date.now();
151
+ if (kind === KIND_PLAY) return normalizeSong(raw.payload, raw, ingestedAt, EVENT_SUBTYPES.MEDIA, "听了");
152
+ if (kind === KIND_FAVORITE) return normalizeSong(raw.payload, raw, ingestedAt, EVENT_SUBTYPES.LIKE, "收藏");
153
+ if (kind === KIND_PLAYLIST) return normalizePlaylist(raw.payload, raw, ingestedAt);
154
+ throw new Error(`NeteaseMusicAdapter.normalize: unknown kind ${kind}`);
155
+ }
156
+ }
157
+
158
+ function buildSource(raw, occurredAt) {
159
+ return {
160
+ adapter: NAME,
161
+ adapterVersion: VERSION,
162
+ originalId: raw.originalId,
163
+ capturedAt: raw.capturedAt || occurredAt,
164
+ capturedBy: CAPTURED_BY.API,
165
+ };
166
+ }
167
+
168
+ function normalizeSong(p, raw, ingestedAt, subtype, verb) {
169
+ const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
170
+ const source = buildSource(raw, occurredAt);
171
+ const song = p.song || "(未知歌曲)";
172
+ const artist = p.artist || "";
173
+ const songId = p.songId != null ? String(p.songId) : null;
174
+ const itemId = songId ? `item-netease-song-${songId}` : `item-netease-song-${newId()}`;
175
+ return {
176
+ events: [{
177
+ id: newId(),
178
+ type: ENTITY_TYPES.EVENT,
179
+ subtype,
180
+ occurredAt,
181
+ actor: "person-self",
182
+ content: { title: `${verb}: ${song}${artist ? " - " + artist : ""}`, text: `${song} ${artist}`.trim() },
183
+ ingestedAt,
184
+ source,
185
+ extra: {
186
+ platform: "netease-music",
187
+ song, artist, album: p.album || null, songId,
188
+ playCount: p.playCount != null ? p.playCount : null,
189
+ itemRef: itemId,
190
+ },
191
+ }],
192
+ items: [{
193
+ id: itemId,
194
+ type: ENTITY_TYPES.ITEM,
195
+ subtype: ITEM_SUBTYPES.MEDIA,
196
+ name: artist ? `${song} - ${artist}` : song,
197
+ ingestedAt,
198
+ source,
199
+ extra: { platform: "netease-music", kind: "song", song, artist, album: p.album || null, songId },
200
+ }],
201
+ persons: [], places: [], topics: [],
202
+ };
203
+ }
204
+
205
+ function normalizePlaylist(p, raw, ingestedAt) {
206
+ const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
207
+ const source = buildSource(raw, occurredAt);
208
+ const pid = p.playlistId != null ? String(p.playlistId) : null;
209
+ return {
210
+ events: [], persons: [], places: [], items: [],
211
+ topics: [{
212
+ id: pid ? `topic-netease-playlist-${pid}` : `topic-netease-playlist-${newId()}`,
213
+ type: ENTITY_TYPES.TOPIC,
214
+ name: p.name || "(未命名歌单)",
215
+ ingestedAt,
216
+ source,
217
+ extra: {
218
+ platform: "netease-music",
219
+ playlistId: pid,
220
+ trackCount: p.trackCount != null ? p.trackCount : null,
221
+ creator: p.creator || null,
222
+ },
223
+ }],
224
+ };
225
+ }
226
+
227
+ module.exports = { NeteaseMusicAdapter, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION, VALID_KINDS };
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * QQ NT **desktop (PC)** adapter — 本地直读样板 (ported from social-douyin /
5
+ * wechat-pc). Reads 新版电脑 QQ 的 nt_msg.db (c2c_msg_table / group_msg_table)
6
+ * straight into the vault as MESSAGE events.
7
+ *
8
+ * Distinct from the Android `messaging-qq` adapter (per-uin <uin>.db, plain
9
+ * SQLite + XOR-IMEI content). QQ NT is SQLCipher-encrypted with numeric
10
+ * obfuscated columns + protobuf message bodies — see nt-db-reader.js for the
11
+ * honest v0.1 caveats. We preserve the full raw row in extra so nothing is
12
+ * lost even when text extraction is partial.
13
+ *
14
+ * Modes:
15
+ * opts.dbPath / opts.inputPath — a (decrypted) nt_msg.db. opts.key (hex)
16
+ * lets the reader attempt SQLCipher directly; otherwise a plaintext DB is
17
+ * expected (decrypt first — the reliable path).
18
+ */
19
+
20
+ const fs = require("node:fs");
21
+ const { newId } = require("../../ids");
22
+ const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
23
+
24
+ const NAME = "qq-pc";
25
+ const VERSION = "0.1.0";
26
+ const KIND_MESSAGE = "message";
27
+
28
+ function stableOriginalId(id) {
29
+ const safe =
30
+ (typeof id === "string" && id.length > 0 && id) ||
31
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
32
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
33
+ return `qq-pc:message:${safe}`;
34
+ }
35
+
36
+ class QQPcAdapter {
37
+ constructor(opts = {}) {
38
+ this._dbPath = opts.dbPath || null;
39
+ this._key = opts.key || null;
40
+
41
+ this.name = NAME;
42
+ this.version = VERSION;
43
+ this.capabilities = [
44
+ "sync:sqlite",
45
+ "decrypt:sqlcipher-qqnt",
46
+ "parse:qq-nt-message",
47
+ ];
48
+ this.extractMode = "device-pull";
49
+ this.rateLimits = {};
50
+ this.dataDisclosure = {
51
+ fields: [
52
+ "qq-pc:messages (time / type / sender / peer / best-effort text from nt_msg.db; raw row preserved)",
53
+ ],
54
+ sensitivity: "high",
55
+ legalGate: true,
56
+ };
57
+
58
+ this._deps = {
59
+ fs,
60
+ dbDriverFactory: opts.dbDriverFactory || null,
61
+ };
62
+ }
63
+
64
+ async authenticate(ctx = {}) {
65
+ if (ctx && ctx.readinessOnly) {
66
+ if (this._dbPath) return { ok: true, mode: "configured" };
67
+ return {
68
+ ok: false,
69
+ reason: "DB_NOT_PULLED",
70
+ message:
71
+ "qq-pc: 需提供电脑版 QQ 的 nt_msg.db 路径(加密库需先解密或提供 key)",
72
+ };
73
+ }
74
+ const dbPath = (ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._dbPath;
75
+ if (dbPath) {
76
+ try {
77
+ this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
78
+ } catch (err) {
79
+ return {
80
+ ok: false,
81
+ reason: "INPUT_PATH_UNREADABLE",
82
+ message: `qq-pc: db not readable at ${dbPath}: ${err.message}`,
83
+ };
84
+ }
85
+ return { ok: true, mode: "sqlite" };
86
+ }
87
+ return {
88
+ ok: false,
89
+ reason: "DB_NOT_PULLED",
90
+ message: "qq-pc.authenticate: needs opts.dbPath / inputPath (nt_msg.db)",
91
+ };
92
+ }
93
+
94
+ async healthCheck() {
95
+ return { ok: true, lastChecked: Date.now() };
96
+ }
97
+
98
+ async *sync(opts = {}) {
99
+ const dbPath = opts.dbPath || opts.inputPath || this._dbPath;
100
+ if (!dbPath) {
101
+ throw new Error("qq-pc.sync: needs opts.dbPath / opts.inputPath (nt_msg.db)");
102
+ }
103
+ if (!this._deps.fs.existsSync(dbPath)) return;
104
+
105
+ // eslint-disable-next-line global-require
106
+ const { readQqNt } = require("./nt-db-reader");
107
+ const readOpts = { key: opts.key || this._key || null };
108
+ if (Number.isInteger(opts.limitMessages)) readOpts.limitMessages = opts.limitMessages;
109
+ if (this._deps.dbDriverFactory) readOpts._databaseClass = this._deps.dbDriverFactory();
110
+
111
+ const { messages, diagnostic } = readQqNt(dbPath, readOpts);
112
+ if (typeof opts.onProgress === "function") {
113
+ try {
114
+ opts.onProgress({ phase: "qq-nt-read", adapter: NAME, ...diagnostic });
115
+ } catch (_e) { /* progress best-effort */ }
116
+ }
117
+
118
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
119
+ const fallbackCapturedAt = Date.now();
120
+ let emitted = 0;
121
+ for (const m of messages) {
122
+ if (emitted >= limit) return;
123
+ if (!m || typeof m !== "object") continue;
124
+ const capturedAt =
125
+ typeof m.createdTimeMs === "number" && m.createdTimeMs > 0
126
+ ? m.createdTimeMs
127
+ : fallbackCapturedAt;
128
+ const idPart =
129
+ m.msgId ||
130
+ (m.peerUin && m.createdTimeMs ? `${m.peerUin}-${m.createdTimeMs}` : `msg-${emitted}`);
131
+ yield {
132
+ adapter: NAME,
133
+ kind: KIND_MESSAGE,
134
+ originalId: stableOriginalId(idPart),
135
+ capturedAt,
136
+ payload: { kind: KIND_MESSAGE, ...m },
137
+ };
138
+ emitted += 1;
139
+ }
140
+ }
141
+
142
+ normalize(raw) {
143
+ if (!raw || !raw.payload) {
144
+ throw new Error("QQPcAdapter.normalize: payload missing");
145
+ }
146
+ const kind = raw.kind || raw.payload.kind;
147
+ if (kind !== KIND_MESSAGE) {
148
+ throw new Error(`QQPcAdapter.normalize: unknown kind ${kind}`);
149
+ }
150
+ const p = raw.payload;
151
+ const ingestedAt = Date.now();
152
+ const occurredAt =
153
+ (typeof p.createdTimeMs === "number" && p.createdTimeMs) || raw.capturedAt || ingestedAt;
154
+ const source = {
155
+ adapter: NAME,
156
+ adapterVersion: VERSION,
157
+ originalId: raw.originalId,
158
+ capturedAt: raw.capturedAt || occurredAt,
159
+ capturedBy: CAPTURED_BY.SQLITE,
160
+ };
161
+ const text = typeof p.text === "string" ? p.text : "";
162
+ return {
163
+ events: [{
164
+ id: newId(),
165
+ type: ENTITY_TYPES.EVENT,
166
+ subtype: EVENT_SUBTYPES.MESSAGE,
167
+ occurredAt,
168
+ actor: "person-self",
169
+ content: {
170
+ title: text ? text.slice(0, 80) : "(待解析消息体)",
171
+ text,
172
+ },
173
+ ingestedAt,
174
+ source,
175
+ extra: {
176
+ platform: "qq",
177
+ source: "pc-nt",
178
+ peerUin: p.peerUin || null,
179
+ senderUin: p.senderUin || null,
180
+ isGroup: !!p.isGroup,
181
+ qqMsgType: typeof p.type === "number" ? p.type : null,
182
+ // Full raw row preserved — protobuf bodies + unknown columns — so a
183
+ // later decoder can backfill text without re-reading the DB.
184
+ rawRow: p.rawRow || null,
185
+ textResolved: typeof p.text === "string" && p.text.length > 0,
186
+ },
187
+ }],
188
+ persons: [],
189
+ places: [],
190
+ items: [],
191
+ topics: [],
192
+ };
193
+ }
194
+ }
195
+
196
+ module.exports = {
197
+ QQPcAdapter,
198
+ NAME,
199
+ VERSION,
200
+ };