@chainlesschain/personal-data-hub 0.2.1 → 0.2.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.
Files changed (39) hide show
  1. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +58 -16
  2. package/__tests__/adapters/wechat-frida-agent.test.js +132 -1
  3. package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
  4. package/__tests__/longtail-adapters.test.js +60 -14
  5. package/__tests__/messaging-qq-snapshot.test.js +294 -0
  6. package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
  7. package/__tests__/shopping-snapshot.test.js +438 -0
  8. package/__tests__/social-adapters.test.js +91 -17
  9. package/__tests__/social-bilibili-snapshot.test.js +278 -0
  10. package/__tests__/social-douyin-snapshot.test.js +253 -0
  11. package/__tests__/social-kuaishou-snapshot.test.js +309 -0
  12. package/__tests__/social-toutiao-snapshot.test.js +314 -0
  13. package/__tests__/social-weibo-snapshot.test.js +234 -0
  14. package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
  15. package/__tests__/travel-maps-snapshot.test.js +426 -0
  16. package/__tests__/vault-driver-error.test.js +74 -0
  17. package/__tests__/wechat-adapter.test.js +118 -0
  18. package/lib/adapters/messaging-qq/index.js +498 -92
  19. package/lib/adapters/shopping-jd/index.js +228 -25
  20. package/lib/adapters/shopping-meituan/index.js +222 -26
  21. package/lib/adapters/shopping-pinduoduo/index.js +275 -0
  22. package/lib/adapters/social-bilibili/adapter.js +500 -0
  23. package/lib/adapters/social-bilibili/index.js +21 -169
  24. package/lib/adapters/social-douyin/index.js +454 -63
  25. package/lib/adapters/social-kuaishou/index.js +379 -127
  26. package/lib/adapters/social-toutiao/index.js +400 -130
  27. package/lib/adapters/social-weibo/index.js +393 -95
  28. package/lib/adapters/social-xiaohongshu/index.js +389 -49
  29. package/lib/adapters/travel-baidu-map/index.js +286 -26
  30. package/lib/adapters/travel-tencent-map/index.js +414 -0
  31. package/lib/adapters/wechat/content-parser.js +11 -2
  32. package/lib/adapters/wechat/db-reader.js +88 -10
  33. package/lib/adapters/wechat/frida-agent/loader.js +7 -0
  34. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +140 -18
  35. package/lib/adapters/wechat/key-providers/frida-key-provider.js +8 -0
  36. package/lib/adapters/wechat/normalize.js +12 -3
  37. package/lib/index.js +5 -1
  38. package/lib/vault.js +60 -8
  39. package/package.json +2 -1
@@ -0,0 +1,414 @@
1
+ /**
2
+ * §2.5b 地图三联 v0.2 — Tencent Map (腾讯地图) adapter, dual-mode (snapshot + sqlite).
3
+ *
4
+ * 新增本 adapter 把地图三联补齐 (amap / baidu-map / tencent-map)。两条路径
5
+ * 与 travel-baidu-map / travel-amap 同 pattern:
6
+ *
7
+ * 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
8
+ * JSON produced by TencentMapLocalCollector (WebView cookie scrape on
9
+ * map.qq.com). Desktop-independent. Adapter stateless — account.
10
+ * deviceId OPTIONAL at construction.
11
+ *
12
+ * 2. sqlite mode (opts.dbPath, future device-pull): scaffold for completeness
13
+ * — table names are educated guess (sjqz/parsers does not yet have a
14
+ * tencent-map parser). Mode runs but trySelect tolerates missing tables.
15
+ * account.deviceId REQUIRED in this mode (checked at sync, not
16
+ * construction).
17
+ *
18
+ * Snapshot schema (mirrors TencentMapLocalCollector.SNAPSHOT_SCHEMA_VERSION):
19
+ *
20
+ * {
21
+ * "schemaVersion": 1,
22
+ * "snapshottedAt": <epoch-ms>,
23
+ * "vendor": "tencent-map",
24
+ * "account": { "uid": "...", "displayName": "..." },
25
+ * "events": [
26
+ * { "kind": "favourite", "id": "fav-<rid>", "capturedAt": <ms>,
27
+ * "name": "...", "address": "...", "lat": .., "lng": .., "category": "home|company|other" },
28
+ * { "kind": "search", "id": "search-<sid>","capturedAt": <ms>,
29
+ * "query": "...", "city": "..." },
30
+ * { "kind": "route", "id": "route-<rid>", "capturedAt": <ms>,
31
+ * "from": {...}, "to": {...}, "mode": "drive|walk|bus|bike|trip" }
32
+ * ]
33
+ * }
34
+ */
35
+
36
+ "use strict";
37
+
38
+ const fs = require("node:fs");
39
+ const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
40
+
41
+ const NAME = "travel-tencent-map";
42
+ const VERSION = "0.2.0";
43
+ const SNAPSHOT_SCHEMA_VERSION = 1;
44
+
45
+ const KIND_FAVOURITE = "favourite";
46
+ const KIND_SEARCH = "search";
47
+ const KIND_ROUTE = "route";
48
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_FAVOURITE, KIND_SEARCH, KIND_ROUTE]);
49
+
50
+ class TencentMapAdapter {
51
+ constructor(opts = {}) {
52
+ // §2.5b v0.2: account.deviceId OPTIONAL — snapshot mode is stateless.
53
+ // Sqlite mode requires it; checked at sync time.
54
+ this.account = opts.account || null;
55
+ this._dbPath = opts.dbPath || null;
56
+
57
+ this.name = NAME;
58
+ this.version = VERSION;
59
+ this.capabilities = [
60
+ "sync:snapshot",
61
+ "sync:sqlite",
62
+ "parse:tencent-map-favourite",
63
+ "parse:tencent-map-history",
64
+ ];
65
+ this.extractMode = "device-pull";
66
+ this.rateLimits = {};
67
+ this.dataDisclosure = {
68
+ fields: [
69
+ "tencent:account (uid / displayName, cookie scrape)",
70
+ "tencent:favourite (saved places — home / company / other)",
71
+ "tencent:search_history (queries, scaffold sqlite mode)",
72
+ "tencent:route_history (planned routes, scaffold sqlite mode)",
73
+ ],
74
+ sensitivity: "medium",
75
+ legalGate: false,
76
+ defaultInclude: {
77
+ favourite: true,
78
+ search: true,
79
+ route: true,
80
+ },
81
+ };
82
+
83
+ this._deps = {
84
+ fs,
85
+ dbDriverFactory: opts.dbDriverFactory || null,
86
+ };
87
+ }
88
+
89
+ async authenticate(ctx = {}) {
90
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
91
+ try {
92
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
93
+ } catch (err) {
94
+ return {
95
+ ok: false,
96
+ reason: "INPUT_PATH_UNREADABLE",
97
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
98
+ };
99
+ }
100
+ return { ok: true, mode: "snapshot-file" };
101
+ }
102
+ if (this._dbPath || (ctx && typeof ctx.dbPath === "string")) {
103
+ if (!this.account || !this.account.deviceId) {
104
+ return {
105
+ ok: false,
106
+ reason: "NO_ACCOUNT_DEVICE_ID",
107
+ message: "travel-tencent-map.authenticate: sqlite mode requires account.deviceId",
108
+ };
109
+ }
110
+ return { ok: true, account: this.account.deviceId, mode: "sqlite" };
111
+ }
112
+ return {
113
+ ok: false,
114
+ reason: "NO_INPUT",
115
+ message:
116
+ "travel-tencent-map.authenticate: needs opts.inputPath (snapshot mode) OR opts.dbPath (sqlite mode)",
117
+ };
118
+ }
119
+
120
+ async healthCheck() {
121
+ return { ok: true, lastChecked: Date.now() };
122
+ }
123
+
124
+ async *sync(opts = {}) {
125
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
126
+ yield* this._syncViaSnapshot(opts);
127
+ return;
128
+ }
129
+ const dbPath = opts.dbPath || this._dbPath;
130
+ if (dbPath) {
131
+ yield* this._syncViaSqlite({ ...opts, dbPath });
132
+ return;
133
+ }
134
+ throw new Error(
135
+ "travel-tencent-map.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.dbPath (sqlite mode)",
136
+ );
137
+ }
138
+
139
+ async *_syncViaSnapshot(opts) {
140
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
141
+ const snapshot = JSON.parse(raw);
142
+ if (
143
+ !snapshot ||
144
+ typeof snapshot !== "object" ||
145
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
146
+ ) {
147
+ throw new Error(
148
+ `travel-tencent-map.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
149
+ );
150
+ }
151
+ const fallbackCapturedAt =
152
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
153
+ ? Math.floor(snapshot.snapshottedAt)
154
+ : Date.now();
155
+ const account =
156
+ snapshot.account && typeof snapshot.account === "object"
157
+ ? snapshot.account
158
+ : null;
159
+ const include = opts.include || {};
160
+ const limit =
161
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
162
+
163
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
164
+ let emitted = 0;
165
+ for (const ev of events) {
166
+ if (emitted >= limit) return;
167
+ if (!ev || typeof ev !== "object") continue;
168
+ const kind = ev.kind;
169
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
170
+ if (include[kind] === false) continue;
171
+
172
+ const capturedAt =
173
+ parseTime(ev.capturedAt) ||
174
+ parseTime(ev.time) ||
175
+ fallbackCapturedAt;
176
+ const id =
177
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
178
+ ev.rid ||
179
+ null;
180
+
181
+ yield {
182
+ adapter: NAME,
183
+ kind,
184
+ originalId: stableOriginalId(kind, id),
185
+ capturedAt,
186
+ payload: { ...ev, account },
187
+ };
188
+ emitted += 1;
189
+ }
190
+ }
191
+
192
+ async *_syncViaSqlite(opts) {
193
+ if (!this.account || !this.account.deviceId) {
194
+ throw new Error(
195
+ "travel-tencent-map._syncViaSqlite: account.deviceId required (set via new TencentMapAdapter({ account: { deviceId } }))",
196
+ );
197
+ }
198
+ const dbPath = opts.dbPath;
199
+ if (!dbPath || !this._deps.fs.existsSync(dbPath)) return;
200
+ const Driver = this._deps.dbDriverFactory
201
+ ? this._deps.dbDriverFactory()
202
+ : require("better-sqlite3-multiple-ciphers");
203
+ const db = new Driver(dbPath, { readonly: true });
204
+
205
+ try {
206
+ // Tencent Map Android app table names (educated guess — sjqz has no
207
+ // parser yet; trySelect tolerates missing tables for forward-compat).
208
+ const routes =
209
+ trySelect(db, "SELECT * FROM route_history LIMIT 5000")
210
+ || trySelect(db, "SELECT * FROM tencent_route_history LIMIT 5000")
211
+ || [];
212
+ for (const r of routes) {
213
+ const rec = routeRowToRecord(r);
214
+ if (rec) {
215
+ yield {
216
+ adapter: NAME,
217
+ originalId: rec.recordId,
218
+ capturedAt: rec.bookedAt || Date.now(),
219
+ payload: { record: rec, kind: KIND_ROUTE },
220
+ };
221
+ }
222
+ }
223
+ const searches =
224
+ trySelect(db, "SELECT * FROM search_history LIMIT 5000")
225
+ || trySelect(db, "SELECT * FROM tencent_search_history LIMIT 5000")
226
+ || [];
227
+ for (const r of searches) {
228
+ const rec = searchRowToRecord(r);
229
+ if (rec) {
230
+ yield {
231
+ adapter: NAME,
232
+ originalId: rec.recordId,
233
+ capturedAt: rec.bookedAt || Date.now(),
234
+ payload: { record: rec, kind: KIND_SEARCH },
235
+ };
236
+ }
237
+ }
238
+ } finally {
239
+ try { db.close(); } catch (_e) { /* ignore */ }
240
+ }
241
+ }
242
+
243
+ normalize(raw) {
244
+ if (!raw || !raw.payload) {
245
+ throw new Error("TencentMapAdapter.normalize: payload missing");
246
+ }
247
+ const kind = raw.kind || raw.payload.kind;
248
+ const p = raw.payload;
249
+
250
+ if (p.record) {
251
+ return normalizeTravelRecord(p.record, {
252
+ adapterName: NAME,
253
+ adapterVersion: VERSION,
254
+ });
255
+ }
256
+ const rec = snapshotEventToRecord(kind, p, raw.originalId);
257
+ return normalizeTravelRecord(rec, {
258
+ adapterName: NAME,
259
+ adapterVersion: VERSION,
260
+ });
261
+ }
262
+ }
263
+
264
+ function stableOriginalId(kind, id) {
265
+ const stringified =
266
+ (typeof id === "string" && id.length > 0 && id) ||
267
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
268
+ null;
269
+ const safe =
270
+ stringified ||
271
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
272
+ return `tencent-map:${kind}:${safe}`;
273
+ }
274
+
275
+ function snapshotEventToRecord(kind, p, originalId) {
276
+ if (kind === KIND_FAVOURITE) {
277
+ return {
278
+ vendorId: "tencentmap",
279
+ recordId: originalId,
280
+ vehicleType: "visit",
281
+ to: {
282
+ name: p.name || p.address || null,
283
+ lat: numberOrNull(p.lat),
284
+ lng: numberOrNull(p.lng),
285
+ city: p.city || null,
286
+ },
287
+ departureMs: parseTime(p.capturedAt),
288
+ carrier: "腾讯地图",
289
+ extras: { category: p.category || null, kind: KIND_FAVOURITE },
290
+ };
291
+ }
292
+ if (kind === KIND_SEARCH) {
293
+ return {
294
+ vendorId: "tencentmap",
295
+ recordId: originalId,
296
+ vehicleType: "visit",
297
+ to: {
298
+ name: p.query || null,
299
+ lat: numberOrNull(p.lat),
300
+ lng: numberOrNull(p.lng),
301
+ city: p.city || null,
302
+ },
303
+ departureMs: parseTime(p.capturedAt),
304
+ carrier: "腾讯地图",
305
+ extras: { query: p.query || null, kind: KIND_SEARCH },
306
+ };
307
+ }
308
+ if (kind === KIND_ROUTE) {
309
+ return {
310
+ vendorId: "tencentmap",
311
+ recordId: originalId,
312
+ vehicleType: detectVehicle(p.mode),
313
+ from: p.from
314
+ ? { name: p.from.name || null, lat: numberOrNull(p.from.lat), lng: numberOrNull(p.from.lng) }
315
+ : undefined,
316
+ to: p.to
317
+ ? { name: p.to.name || null, lat: numberOrNull(p.to.lat), lng: numberOrNull(p.to.lng) }
318
+ : undefined,
319
+ departureMs: parseTime(p.capturedAt),
320
+ carrier: "腾讯地图",
321
+ extras: { mode: p.mode || null, kind: KIND_ROUTE },
322
+ };
323
+ }
324
+ return {
325
+ vendorId: "tencentmap",
326
+ recordId: originalId,
327
+ vehicleType: "visit",
328
+ carrier: "腾讯地图",
329
+ extras: { kind, raw: p },
330
+ };
331
+ }
332
+
333
+ function trySelect(db, sql) {
334
+ try {
335
+ return db.prepare(sql).all();
336
+ } catch (_e) {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ function routeRowToRecord(row) {
342
+ if (!row) return null;
343
+ const id = row._id || row.id || row.uid;
344
+ if (!id) return null;
345
+ return {
346
+ vendorId: "tencentmap",
347
+ recordId: `route-${id}`,
348
+ vehicleType: detectVehicle(row.type || row.mode),
349
+ from: { name: row.start_name || row.from_name, lat: row.start_lat || null, lng: row.start_lng || null },
350
+ to: { name: row.end_name || row.to_name, lat: row.end_lat || null, lng: row.end_lng || null },
351
+ departureMs: numberOrParse(row.time || row.create_time),
352
+ carrier: "腾讯地图",
353
+ extras: { mode: row.type || row.mode },
354
+ };
355
+ }
356
+
357
+ function searchRowToRecord(row) {
358
+ if (!row) return null;
359
+ const id = row._id || row.id;
360
+ if (!id) return null;
361
+ return {
362
+ vendorId: "tencentmap",
363
+ recordId: `search-${id}`,
364
+ vehicleType: "visit",
365
+ to: { name: row.key || row.query || row.keyword, lat: row.lat || null, lng: row.lng || null, city: row.city },
366
+ departureMs: numberOrParse(row.time || row.create_time),
367
+ carrier: "腾讯地图",
368
+ extras: { query: row.key || row.query || row.keyword },
369
+ };
370
+ }
371
+
372
+ function detectVehicle(v) {
373
+ const s = String(v || "").toLowerCase();
374
+ if (s.includes("drive") || s.includes("car")) return "car";
375
+ if (s.includes("walk")) return "walk";
376
+ if (s.includes("bike") || s.includes("cycle")) return "bike";
377
+ if (s.includes("bus") || s.includes("transit")) return "bus";
378
+ return "trip";
379
+ }
380
+
381
+ function numberOrNull(v) {
382
+ if (Number.isFinite(v)) return v;
383
+ if (typeof v === "string" && /^-?\d+(\.\d+)?$/.test(v)) return parseFloat(v);
384
+ return null;
385
+ }
386
+
387
+ function parseTime(v) {
388
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
389
+ if (typeof v === "string") {
390
+ if (/^\d+$/.test(v)) {
391
+ const n = parseInt(v, 10);
392
+ return n > 1e12 ? n : n * 1000;
393
+ }
394
+ const parsed = parseChineseDateTime(v);
395
+ if (Number.isFinite(parsed)) return parsed;
396
+ const t = Date.parse(v);
397
+ return Number.isFinite(t) ? t : null;
398
+ }
399
+ return null;
400
+ }
401
+
402
+ function numberOrParse(v) {
403
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
404
+ if (typeof v === "string") {
405
+ if (/^\d+$/.test(v)) {
406
+ const n = parseInt(v, 10);
407
+ return n > 1e12 ? n : n * 1000;
408
+ }
409
+ return parseChineseDateTime(v);
410
+ }
411
+ return null;
412
+ }
413
+
414
+ module.exports = { TencentMapAdapter, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };
@@ -54,6 +54,10 @@ const APPMSG_SUBTYPES = {
54
54
  33: "miniprogram",
55
55
  36: "miniprogram",
56
56
  51: "channel-video",
57
+ // sjqz docs reference these higher subtype codes on newer WeChat builds —
58
+ // accept both for forward compatibility (post-Phase 12.6 audit).
59
+ 2000: "transfer",
60
+ 2001: "redpacket",
57
61
  };
58
62
 
59
63
  /**
@@ -225,10 +229,15 @@ function parseAppMsg(body) {
225
229
  url: url || null,
226
230
  };
227
231
 
228
- // Redpacket-specific
229
- if (appType === 21) {
232
+ // Redpacket-specific (accept both 21 and 2001 — see APPMSG_SUBTYPES)
233
+ if (appType === 21 || appType === 2001) {
230
234
  structured.redPacketTitle = title;
231
235
  }
236
+ // Transfer-specific
237
+ if (appType === 2000) {
238
+ structured.transferAmount =
239
+ extractTag(body, "feedesc") || extractTag(body, "pay_memo");
240
+ }
232
241
  // File-specific
233
242
  if (appType === 6) {
234
243
  structured.fileName = title;
@@ -129,27 +129,88 @@ class WeChatDBReader {
129
129
  .map((r) => r.name);
130
130
  }
131
131
 
132
+ /**
133
+ * Discover actual column names via `PRAGMA table_info(<table>)` so
134
+ * uppercase/lowercase divergence across WeChat builds doesn't blow up
135
+ * the SELECT. Returns a Map<lowercased_name, actual_name>.
136
+ *
137
+ * Post-sjqz audit defence — sjqz schema docs show some column-case
138
+ * variation across versions; failing late at SELECT yields a confusing
139
+ * "no such column" error rather than a clean fallback path.
140
+ */
141
+ _columnMap(table) {
142
+ if (!this._db) return new Map();
143
+ try {
144
+ const rows = this._db.prepare(`PRAGMA table_info(${table})`).all();
145
+ return new Map(rows.map((r) => [String(r.name).toLowerCase(), r.name]));
146
+ } catch (_e) {
147
+ return new Map();
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Resolve a list of desired column names against the actual table
153
+ * schema. Returns the actual column names quoted for SQL use; throws
154
+ * if any required column is missing (caller catches and surfaces a
155
+ * "schema-mismatch" error to the host).
156
+ */
157
+ _resolveColumns(table, desiredNames, { required = true } = {}) {
158
+ const map = this._columnMap(table);
159
+ const resolved = [];
160
+ const missing = [];
161
+ for (const name of desiredNames) {
162
+ const actual = map.get(name.toLowerCase());
163
+ if (actual) resolved.push(actual);
164
+ else if (required) missing.push(name);
165
+ }
166
+ if (missing.length > 0 && required) {
167
+ const err = new Error(
168
+ `WeChatDBReader: table '${table}' missing required columns: ${missing.join(", ")} ` +
169
+ `(available: ${Array.from(map.values()).join(", ")})`,
170
+ );
171
+ err.code = "WECHAT_SCHEMA_MISMATCH";
172
+ throw err;
173
+ }
174
+ return resolved;
175
+ }
176
+
132
177
  /**
133
178
  * Fetch up to `limit` messages since `sinceMsgSvrId` (per design doc
134
179
  * §6 OQ-6 watermark = per-talker last msgSvrId). For initial v0 we
135
180
  * accept a global watermark and let the adapter post-filter per
136
181
  * talker.
182
+ *
183
+ * Column names resolved via PRAGMA table_info to survive case-drift
184
+ * across WeChat versions (sjqz audit defence).
137
185
  */
138
186
  fetchMessages({ sinceMsgSvrId = 0, limit = 1000, talker = null } = {}) {
139
187
  if (!this._db) throw new Error("WeChatDBReader: call open() first");
140
- let sql = "SELECT msgId, msgSvrId, talker, content, type, createTime, isSend, status FROM message";
188
+ const cols = this._resolveColumns("message", [
189
+ "msgId",
190
+ "msgSvrId",
191
+ "talker",
192
+ "content",
193
+ "type",
194
+ "createTime",
195
+ "isSend",
196
+ "status",
197
+ ]);
198
+ let sql = `SELECT ${cols.join(", ")} FROM message`;
141
199
  const params = [];
142
200
  const where = [];
143
201
  if (sinceMsgSvrId) {
144
- where.push("msgSvrId > ?");
202
+ // Use the resolved column name in WHERE / ORDER BY to match case.
203
+ const msgSvrIdCol = cols[1];
204
+ where.push(`${msgSvrIdCol} > ?`);
145
205
  params.push(sinceMsgSvrId);
146
206
  }
147
207
  if (talker) {
148
- where.push("talker = ?");
208
+ const talkerCol = cols[2];
209
+ where.push(`${talkerCol} = ?`);
149
210
  params.push(talker);
150
211
  }
151
212
  if (where.length > 0) sql += " WHERE " + where.join(" AND ");
152
- sql += " ORDER BY msgSvrId ASC LIMIT ?";
213
+ sql += ` ORDER BY ${cols[1]} ASC LIMIT ?`;
153
214
  params.push(limit);
154
215
  return this._db.prepare(sql).all(...params);
155
216
  }
@@ -157,14 +218,31 @@ class WeChatDBReader {
157
218
  /**
158
219
  * Fetch contacts. WeChat rcontact has many columns; we pull the ones
159
220
  * relevant for normalization.
221
+ *
222
+ * sjqz parity (wechat.py:262-263): excludes `@stranger` (unconfirmed
223
+ * friend requests) and `fake_*` (WeChat internal placeholder accounts).
224
+ * Without this filter the vault gets polluted with junk Person entities
225
+ * that never represent real contacts.
226
+ *
227
+ * @param {object} [opts]
228
+ * @param {number} [opts.limit=5000]
229
+ * @param {boolean} [opts.includeJunk=false] true to skip the
230
+ * stranger/fake filter (debug / forensic use only)
160
231
  */
161
- fetchContacts({ limit = 5000 } = {}) {
232
+ fetchContacts({ limit = 5000, includeJunk = false } = {}) {
162
233
  if (!this._db) throw new Error("WeChatDBReader: call open() first");
163
- return this._db
164
- .prepare(
165
- "SELECT username, alias, nickname, conRemark, type FROM rcontact LIMIT ?",
166
- )
167
- .all(limit);
234
+ const cols = this._resolveColumns("rcontact", [
235
+ "username",
236
+ "alias",
237
+ "nickname",
238
+ "conRemark",
239
+ "type",
240
+ ]);
241
+ const usernameCol = cols[0];
242
+ const sql = includeJunk
243
+ ? `SELECT ${cols.join(", ")} FROM rcontact LIMIT ?`
244
+ : `SELECT ${cols.join(", ")} FROM rcontact WHERE ${usernameCol} NOT LIKE '%@stranger' AND ${usernameCol} NOT LIKE 'fake_%' LIMIT ?`;
245
+ return this._db.prepare(sql).all(limit);
168
246
  }
169
247
 
170
248
  /**
@@ -53,6 +53,13 @@ function runAgentUnderMock(mocks = {}) {
53
53
  Interceptor: mocks.Interceptor,
54
54
  send: mocks.send,
55
55
  setTimeout: mocks.setTimeout || setTimeout,
56
+ // Frida injects Memory at runtime; tests that exercise the ascii-hex
57
+ // key-read path inject a mock with readCString(ptr, maxLen). Tests
58
+ // that don't touch it get a no-op stub so the agent module loads
59
+ // cleanly even when the hook itself never calls readCString.
60
+ Memory: mocks.Memory || {
61
+ readCString: () => null,
62
+ },
56
63
  };
57
64
  const ctx = vm.createContext(sandbox);
58
65
  const src = loadAgentScript();