@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.
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +58 -16
- package/__tests__/adapters/wechat-frida-agent.test.js +132 -1
- package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
- package/__tests__/longtail-adapters.test.js +60 -14
- package/__tests__/messaging-qq-snapshot.test.js +294 -0
- package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
- package/__tests__/shopping-snapshot.test.js +438 -0
- package/__tests__/social-adapters.test.js +91 -17
- package/__tests__/social-bilibili-snapshot.test.js +278 -0
- package/__tests__/social-douyin-snapshot.test.js +253 -0
- package/__tests__/social-kuaishou-snapshot.test.js +309 -0
- package/__tests__/social-toutiao-snapshot.test.js +314 -0
- package/__tests__/social-weibo-snapshot.test.js +234 -0
- package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
- package/__tests__/travel-maps-snapshot.test.js +426 -0
- package/__tests__/vault-driver-error.test.js +74 -0
- package/__tests__/wechat-adapter.test.js +118 -0
- package/lib/adapters/messaging-qq/index.js +498 -92
- package/lib/adapters/shopping-jd/index.js +228 -25
- package/lib/adapters/shopping-meituan/index.js +222 -26
- package/lib/adapters/shopping-pinduoduo/index.js +275 -0
- package/lib/adapters/social-bilibili/adapter.js +500 -0
- package/lib/adapters/social-bilibili/index.js +21 -169
- package/lib/adapters/social-douyin/index.js +454 -63
- package/lib/adapters/social-kuaishou/index.js +379 -127
- package/lib/adapters/social-toutiao/index.js +400 -130
- package/lib/adapters/social-weibo/index.js +393 -95
- package/lib/adapters/social-xiaohongshu/index.js +389 -49
- package/lib/adapters/travel-baidu-map/index.js +286 -26
- package/lib/adapters/travel-tencent-map/index.js +414 -0
- package/lib/adapters/wechat/content-parser.js +11 -2
- package/lib/adapters/wechat/db-reader.js +88 -10
- package/lib/adapters/wechat/frida-agent/loader.js +7 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +140 -18
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +8 -0
- package/lib/adapters/wechat/normalize.js +12 -3
- package/lib/index.js +5 -1
- package/lib/vault.js +60 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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();
|