@chainlesschain/personal-data-hub 0.4.3 → 0.4.4

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.
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const os = require("node:os");
7
+ const crypto = require("node:crypto");
8
+
9
+ const {
10
+ TencentMapAdapter,
11
+ NAME,
12
+ VERSION,
13
+ SNAPSHOT_SCHEMA_VERSION,
14
+ } = require("../../lib/adapters/travel-tencent-map");
15
+
16
+ function writeTmp(content, ext = "json") {
17
+ const p = path.join(
18
+ os.tmpdir(),
19
+ `cc-tencentmap-test-${crypto.randomUUID()}.${ext}`,
20
+ );
21
+ fs.writeFileSync(p, content, "utf-8");
22
+ return p;
23
+ }
24
+
25
+ async function collect(gen) {
26
+ const out = [];
27
+ for await (const x of gen) out.push(x);
28
+ return out;
29
+ }
30
+
31
+ function makeFakeDriverFactory(tables, log = {}) {
32
+ return () =>
33
+ class FakeDb {
34
+ constructor(dbPath, opts) {
35
+ log.opened = { dbPath, opts };
36
+ }
37
+ prepare(sql) {
38
+ for (const [needle, rows] of Object.entries(tables)) {
39
+ if (sql.includes(needle)) return { all: () => rows };
40
+ }
41
+ throw new Error(`no such table in: ${sql}`);
42
+ }
43
+ close() {
44
+ log.closed = true;
45
+ }
46
+ };
47
+ }
48
+
49
+ const SNAPSHOT = {
50
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
51
+ snapshottedAt: 1716383021000,
52
+ vendor: "tencent-map",
53
+ account: { uid: "U1" },
54
+ events: [
55
+ {
56
+ kind: "favourite",
57
+ id: "fav-1",
58
+ capturedAt: 1716383021000,
59
+ name: "公司",
60
+ lat: 31.2,
61
+ lng: 121.44,
62
+ category: "company",
63
+ },
64
+ {
65
+ kind: "route",
66
+ id: "route-2",
67
+ capturedAt: 1716383021000,
68
+ from: { name: "公司" },
69
+ to: { name: "体育馆" },
70
+ mode: "bike",
71
+ },
72
+ ],
73
+ };
74
+
75
+ describe("constants", () => {
76
+ it("exposes name/version/schema", () => {
77
+ expect(NAME).toBe("travel-tencent-map");
78
+ expect(VERSION).toBe("0.2.0");
79
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
80
+ });
81
+ });
82
+
83
+ describe("authenticate", () => {
84
+ it("mirrors baidu: snapshot / sqlite-needs-deviceId / NO_INPUT", async () => {
85
+ const p = writeTmp("{}");
86
+ try {
87
+ const a = new TencentMapAdapter();
88
+ expect((await a.authenticate({ inputPath: p })).mode).toBe(
89
+ "snapshot-file",
90
+ );
91
+ expect(
92
+ (
93
+ await new TencentMapAdapter({ dbPath: "x.db" }).authenticate({})
94
+ ).reason,
95
+ ).toBe("NO_ACCOUNT_DEVICE_ID");
96
+ expect((await a.authenticate({})).reason).toBe("NO_INPUT");
97
+ } finally {
98
+ fs.unlinkSync(p);
99
+ }
100
+ });
101
+ });
102
+
103
+ describe("sync — snapshot mode", () => {
104
+ it("yields events with tencent-map: prefixed originalId", async () => {
105
+ const p = writeTmp(JSON.stringify(SNAPSHOT));
106
+ try {
107
+ const a = new TencentMapAdapter();
108
+ const items = await collect(a.sync({ inputPath: p }));
109
+ expect(items.map((i) => i.originalId)).toEqual([
110
+ "tencent-map:favourite:fav-1",
111
+ "tencent-map:route:route-2",
112
+ ]);
113
+ expect(items[0].payload.account).toEqual({ uid: "U1" });
114
+ } finally {
115
+ fs.unlinkSync(p);
116
+ }
117
+ });
118
+
119
+ it("throws on schemaVersion mismatch", async () => {
120
+ const p = writeTmp(JSON.stringify({ schemaVersion: 2, events: [] }));
121
+ try {
122
+ await expect(
123
+ collect(new TencentMapAdapter().sync({ inputPath: p })),
124
+ ).rejects.toThrow(/schemaVersion mismatch/);
125
+ } finally {
126
+ fs.unlinkSync(p);
127
+ }
128
+ });
129
+ });
130
+
131
+ describe("sync — sqlite mode (fake driver)", () => {
132
+ it("yields rows + falls back to tencent_* legacy tables (keyword alias)", async () => {
133
+ const p = writeTmp("fake", "db");
134
+ const log = {};
135
+ try {
136
+ const a = new TencentMapAdapter({
137
+ dbPath: p,
138
+ account: { deviceId: "DEV1" },
139
+ dbDriverFactory: makeFakeDriverFactory(
140
+ {
141
+ tencent_route_history: [
142
+ {
143
+ _id: 5,
144
+ mode: "walk",
145
+ start_name: "家",
146
+ end_name: "菜场",
147
+ time: 1716383021,
148
+ },
149
+ ],
150
+ tencent_search_history: [
151
+ { _id: 6, keyword: "奶茶", city: "深圳", time: 1716383021000 },
152
+ ],
153
+ },
154
+ log,
155
+ ),
156
+ });
157
+ const items = await collect(a.sync({}));
158
+ expect(items).toHaveLength(2);
159
+ expect(items[0].payload.record).toMatchObject({
160
+ vendorId: "tencentmap",
161
+ recordId: "route-5",
162
+ vehicleType: "walk",
163
+ carrier: "腾讯地图",
164
+ departureMs: 1716383021 * 1000,
165
+ });
166
+ // searchRowToRecord accepts the tencent `keyword` alias
167
+ expect(items[1].payload.record.to).toMatchObject({
168
+ name: "奶茶",
169
+ city: "深圳",
170
+ });
171
+ expect(log.closed).toBe(true);
172
+ } finally {
173
+ fs.unlinkSync(p);
174
+ }
175
+ });
176
+
177
+ it("requires account.deviceId at sync time", async () => {
178
+ const a = new TencentMapAdapter({ dbPath: "x.db" });
179
+ await expect(collect(a.sync({}))).rejects.toThrow(
180
+ /account\.deviceId required/,
181
+ );
182
+ });
183
+ });
184
+
185
+ describe("normalize", () => {
186
+ it("snapshot route → bike trip titled by place names", async () => {
187
+ const p = writeTmp(JSON.stringify(SNAPSHOT));
188
+ try {
189
+ const a = new TencentMapAdapter();
190
+ const [fav, route] = await collect(a.sync({ inputPath: p }));
191
+ expect(a.normalize(fav).events[0].content.title).toBe("visit: → 公司");
192
+ const batch = a.normalize(route);
193
+ expect(batch.events[0].content.title).toBe("bike: 公司 → 体育馆");
194
+ expect(
195
+ batch.persons.find((x) => x.subtype === "merchant").names,
196
+ ).toEqual(["腾讯地图"]);
197
+ } finally {
198
+ fs.unlinkSync(p);
199
+ }
200
+ });
201
+
202
+ it("throws on missing payload", () => {
203
+ expect(() => new TencentMapAdapter().normalize(null)).toThrow(
204
+ /payload missing/,
205
+ );
206
+ });
207
+ });
@@ -54,8 +54,13 @@ const KIND_PROFILE = "profile";
54
54
  const KIND_WATCH = "watch";
55
55
  const KIND_COLLECT = "collect";
56
56
  const KIND_SEARCH = "search";
57
- // v0.2.1 — KIND_PROFILE added (mirrors Douyin/Toutiao); v0.3 will add watch/
58
- // collect/search via NS_sig3. SNAPSHOT_SCHEMA_VERSION stays at 1 — additive.
57
+ // v0.2.1 — KIND_PROFILE added (mirrors Douyin/Toutiao). The watch/collect/
58
+ // search producers LANDED since (verified 2026-06-11): Android
59
+ // KuaishouLocalCollector emits all 4 kinds via the NS_sig3 WebSignBridge
60
+ // path, KuaishouRootDbExtractor emits watch/collect/search, and the PC ADB
61
+ // KuaishouApiClient fetches them through its injected signProvider (signed
62
+ // GraphQL). This adapter normalizes whatever the snapshot carries.
63
+ // SNAPSHOT_SCHEMA_VERSION stays at 1 — additive.
59
64
  const VALID_SNAPSHOT_KINDS = Object.freeze([
60
65
  KIND_PROFILE,
61
66
  KIND_WATCH,
@@ -119,23 +119,19 @@ class KuaishouApiClient {
119
119
  );
120
120
  return null;
121
121
  }
122
- let decoded;
123
- try {
124
- decoded = decodeURIComponent(cpMatch[1]);
125
- } catch {
126
- decoded = cpMatch[1];
127
- }
128
- const trimmed = decoded.trimStart();
129
- if (!trimmed.startsWith("{")) {
122
+ const jsonText = apiPhDecodeCandidates(cpMatch[1]).find((c) =>
123
+ c.trimStart().startsWith("{"),
124
+ );
125
+ if (!jsonText) {
130
126
  this._setLastError(
131
127
  -9,
132
- "kuaishou.web.cp.api_ph 解码后非 JSON (likely base64 v0.3 加 fallback)",
128
+ "kuaishou.web.cp.api_ph 解码后非 JSON (urlencoded + base64 fallback 均失败)",
133
129
  );
134
130
  return null;
135
131
  }
136
132
  let obj;
137
133
  try {
138
- obj = JSON.parse(decoded);
134
+ obj = JSON.parse(jsonText);
139
135
  } catch (e) {
140
136
  this._setLastError(-3, "parse: " + (e.message || String(e)));
141
137
  return null;
@@ -359,20 +355,43 @@ function extractPhotoList(feeds, limit, build) {
359
355
  return out;
360
356
  }
361
357
 
362
- function extractEmbeddedUid(cpRaw) {
358
+ /**
359
+ * api_ph payload decode chain (v0.3): newer Kuaishou builds write the
360
+ * `kuaishou.web.cp.api_ph` cookie as base64(JSON) instead of urlencoded
361
+ * JSON. Yields the URI-decoded string first; when that doesn't look like
362
+ * JSON but matches the base64 charset (std or url-safe), also yields the
363
+ * base64-decoded form — gated on the result starting with `{` so lenient
364
+ * Buffer decoding of arbitrary text can't surface garbage.
365
+ */
366
+ function apiPhDecodeCandidates(cpRaw) {
363
367
  let decoded;
364
368
  try {
365
369
  decoded = decodeURIComponent(cpRaw);
366
370
  } catch {
367
371
  decoded = cpRaw;
368
372
  }
369
- for (const pat of [
370
- /"?user_id"?\s*:\s*"?(\d+)"?/,
371
- /"?uid"?\s*:\s*"?(\d+)"?/,
372
- /"?userId"?\s*:\s*"?(\d+)"?/,
373
- ]) {
374
- const m = pat.exec(decoded);
375
- if (m && m[1] && m[1] !== "0") return m[1];
373
+ const out = [decoded];
374
+ const trimmed = decoded.trim();
375
+ if (!trimmed.startsWith("{") && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(trimmed)) {
376
+ const b64 = Buffer.from(
377
+ trimmed.replace(/-/g, "+").replace(/_/g, "/"),
378
+ "base64",
379
+ ).toString("utf-8");
380
+ if (b64.trimStart().startsWith("{")) out.push(b64);
381
+ }
382
+ return out;
383
+ }
384
+
385
+ function extractEmbeddedUid(cpRaw) {
386
+ for (const decoded of apiPhDecodeCandidates(cpRaw)) {
387
+ for (const pat of [
388
+ /"?user_id"?\s*:\s*"?(\d+)"?/,
389
+ /"?uid"?\s*:\s*"?(\d+)"?/,
390
+ /"?userId"?\s*:\s*"?(\d+)"?/,
391
+ ]) {
392
+ const m = pat.exec(decoded);
393
+ if (m && m[1] && m[1] !== "0") return m[1];
394
+ }
376
395
  }
377
396
  return null;
378
397
  }
@@ -393,5 +412,6 @@ module.exports = {
393
412
  normalizeMs,
394
413
  extractPhotoList,
395
414
  extractEmbeddedUid,
415
+ apiPhDecodeCandidates,
396
416
  },
397
417
  };
@@ -34,6 +34,9 @@ const crypto = require("node:crypto");
34
34
  const {
35
35
  readChromiumCookies,
36
36
  } = require("../social-bilibili-adb/chromium-cookies-reader");
37
+ const {
38
+ _internals: { apiPhDecodeCandidates },
39
+ } = require("./api-client");
37
40
 
38
41
  const KUAISHOU_COOKIES_REMOTE_PATH =
39
42
  "/data/data/com.smile.gifmaker/app_webview/Default/Cookies";
@@ -137,22 +140,20 @@ function pickUidFromCookieMap(byName) {
137
140
  }
138
141
  const cpRaw = byName.get("kuaishou.web.cp.api_ph")?.value;
139
142
  if (cpRaw) {
140
- let decoded;
141
- try {
142
- decoded = decodeURIComponent(cpRaw);
143
- } catch {
144
- decoded = cpRaw;
145
- }
146
143
  // Try nested user_id / uid / userId regex (don't require strict JSON
147
- // — api_ph format isn't documented and varies)
148
- for (const pat of [
149
- /"?user_id"?\s*:\s*"?(\d+)"?/,
150
- /"?uid"?\s*:\s*"?(\d+)"?/,
151
- /"?userId"?\s*:\s*"?(\d+)"?/,
152
- ]) {
153
- const m = pat.exec(decoded);
154
- if (m && m[1] && m[1] !== "0") {
155
- return m[1];
144
+ // — api_ph format isn't documented and varies). v0.3: candidates
145
+ // include the base64-decoded form for newer Kuaishou builds that
146
+ // write api_ph as base64(JSON).
147
+ for (const decoded of apiPhDecodeCandidates(cpRaw)) {
148
+ for (const pat of [
149
+ /"?user_id"?\s*:\s*"?(\d+)"?/,
150
+ /"?uid"?\s*:\s*"?(\d+)"?/,
151
+ /"?userId"?\s*:\s*"?(\d+)"?/,
152
+ ]) {
153
+ const m = pat.exec(decoded);
154
+ if (m && m[1] && m[1] !== "0") {
155
+ return m[1];
156
+ }
156
157
  }
157
158
  }
158
159
  }
@@ -57,10 +57,14 @@ const KIND_PROFILE = "profile";
57
57
  const KIND_READ = "read";
58
58
  const KIND_COLLECTION = "collection";
59
59
  const KIND_SEARCH = "search";
60
- // v0.2.1 — KIND_PROFILE added (mirrors Douyin); v0.3 will add read/collection
61
- // /search once _signature path is wired. SNAPSHOT_SCHEMA_VERSION stays at 1:
62
- // old (events-only) snapshots remain compatible; new profile events are an
63
- // additive extension.
60
+ // v0.2.1 — KIND_PROFILE added (mirrors Douyin). The read/collection/search
61
+ // producers LANDED since (verified 2026-06-11): Android ToutiaoLocalCollector
62
+ // emits all 4 kinds via the _signature WebSignBridge path, ToutiaoRootDbExtractor
63
+ // emits read/collection/search, and the PC ADB ToutiaoApiClient fetches
64
+ // feed/collection/search through its injected signProvider. This adapter
65
+ // normalizes whatever the snapshot carries. SNAPSHOT_SCHEMA_VERSION stays
66
+ // at 1: old (events-only) snapshots remain compatible; profile events are
67
+ // an additive extension.
64
68
  const VALID_SNAPSHOT_KINDS = Object.freeze([
65
69
  KIND_PROFILE,
66
70
  KIND_READ,
@@ -150,8 +150,15 @@ function normalizeTravelRecord(rec, ctx = {}) {
150
150
 
151
151
  function buildTitle(rec) {
152
152
  const vt = rec.vehicleType || "trip";
153
- const from = rec.from ? (rec.from.station || rec.from.city || "?") : "";
154
- const to = rec.to ? (rec.to.station || rec.to.city || "?") : "";
153
+ // station > city > name name matters for Amap route/search records,
154
+ // which carry ONLY p.name (no station/city); without it every Amap trip
155
+ // event was titled "car: ? → ?".
156
+ const from = rec.from
157
+ ? (rec.from.station || rec.from.city || rec.from.name || "?")
158
+ : "";
159
+ const to = rec.to
160
+ ? (rec.to.station || rec.to.city || rec.to.name || "?")
161
+ : "";
155
162
  if (from && to) return `${vt}: ${from} → ${to}`;
156
163
  if (to) return `${vt}: → ${to}`;
157
164
  return `${vt}: ${rec.carrier || rec.recordId}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlesschain/personal-data-hub",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Personal Data Hub — UnifiedSchema + validators + KG ingest helpers for the data-back-to-the-individual middleware",
5
5
  "type": "commonjs",
6
6
  "main": "lib/index.js",