@chainlesschain/personal-data-hub 0.1.0 → 0.2.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 (154) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +396 -0
  4. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  5. package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
  6. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  7. package/__tests__/adapters/email-adapter.test.js +138 -1
  8. package/__tests__/adapters/email-classifier.test.js +347 -0
  9. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  10. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  11. package/__tests__/adapters/email-templates.test.js +699 -0
  12. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  13. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  14. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  15. package/__tests__/adapters/system-data-android.test.js +387 -0
  16. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  17. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  18. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  19. package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
  20. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  21. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  22. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  23. package/__tests__/analysis-skills.test.js +556 -0
  24. package/__tests__/analysis.test.js +329 -1
  25. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  26. package/__tests__/e2e/full-user-journey.test.js +188 -0
  27. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  28. package/__tests__/entity-resolver-stages.test.js +411 -0
  29. package/__tests__/entity-resolver-vault.test.js +246 -0
  30. package/__tests__/entity-resolver.test.js +526 -0
  31. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  32. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  33. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  34. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  35. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  36. package/__tests__/longtail-adapters.test.js +217 -0
  37. package/__tests__/mobile-extractor.test.js +288 -0
  38. package/__tests__/registry.test.js +4 -2
  39. package/__tests__/shopping-adapters.test.js +296 -0
  40. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  41. package/__tests__/sidecar-supervisor.test.js +120 -0
  42. package/__tests__/social-adapters.test.js +206 -0
  43. package/__tests__/travel-adapters.test.js +325 -0
  44. package/__tests__/vault.test.js +3 -3
  45. package/__tests__/wechat-adapter.test.js +476 -0
  46. package/__tests__/whatsapp-adapter.test.js +135 -0
  47. package/lib/adapter-spec.js +12 -0
  48. package/lib/adapters/_python-sidecar-base.js +207 -0
  49. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
  50. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  51. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  52. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  53. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  54. package/lib/adapters/ai-chat-history/index.js +28 -0
  55. package/lib/adapters/ai-chat-history/schema-map.js +258 -0
  56. package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
  57. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  58. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  59. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  60. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  61. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  62. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  63. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  64. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  65. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  66. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  67. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
  68. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  69. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  70. package/lib/adapters/alipay-bill/index.js +41 -0
  71. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  72. package/lib/adapters/email-imap/classifier.js +495 -0
  73. package/lib/adapters/email-imap/email-adapter.js +419 -8
  74. package/lib/adapters/email-imap/index.js +42 -0
  75. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  76. package/lib/adapters/email-imap/templates/bill.js +232 -0
  77. package/lib/adapters/email-imap/templates/government.js +120 -0
  78. package/lib/adapters/email-imap/templates/index.js +78 -0
  79. package/lib/adapters/email-imap/templates/order.js +186 -0
  80. package/lib/adapters/email-imap/templates/other.js +114 -0
  81. package/lib/adapters/email-imap/templates/register.js +113 -0
  82. package/lib/adapters/email-imap/templates/travel.js +157 -0
  83. package/lib/adapters/email-imap/templates/utils.js +275 -0
  84. package/lib/adapters/email-imap/transactions.js +234 -0
  85. package/lib/adapters/messaging-qq/index.js +158 -0
  86. package/lib/adapters/messaging-telegram/index.js +142 -0
  87. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  88. package/lib/adapters/shopping-base/index.js +208 -0
  89. package/lib/adapters/shopping-jd/index.js +150 -0
  90. package/lib/adapters/shopping-meituan/index.js +154 -0
  91. package/lib/adapters/shopping-taobao/index.js +176 -0
  92. package/lib/adapters/social-bilibili/index.js +171 -0
  93. package/lib/adapters/social-douyin/index.js +116 -0
  94. package/lib/adapters/social-kuaishou/index.js +237 -0
  95. package/lib/adapters/social-toutiao/index.js +236 -0
  96. package/lib/adapters/social-weibo/index.js +164 -0
  97. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  98. package/lib/adapters/system-data/disclosure.js +166 -0
  99. package/lib/adapters/system-data/index.js +34 -0
  100. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  101. package/lib/adapters/system-data-android/adapter.js +348 -0
  102. package/lib/adapters/system-data-android/index.js +76 -0
  103. package/lib/adapters/travel-12306/index.js +151 -0
  104. package/lib/adapters/travel-amap/index.js +164 -0
  105. package/lib/adapters/travel-baidu-map/index.js +162 -0
  106. package/lib/adapters/travel-base/index.js +240 -0
  107. package/lib/adapters/travel-ctrip/index.js +151 -0
  108. package/lib/adapters/wechat/bootstrap.js +146 -0
  109. package/lib/adapters/wechat/content-parser.js +326 -0
  110. package/lib/adapters/wechat/db-reader.js +209 -0
  111. package/lib/adapters/wechat/env-probe.js +218 -0
  112. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  113. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  114. package/lib/adapters/wechat/index.js +37 -0
  115. package/lib/adapters/wechat/key-extractor.js +158 -0
  116. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  117. package/lib/adapters/wechat/key-providers/index.js +22 -0
  118. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  119. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  120. package/lib/adapters/wechat/normalize.js +220 -0
  121. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  122. package/lib/analysis-skills/base.js +113 -0
  123. package/lib/analysis-skills/footprint.js +167 -0
  124. package/lib/analysis-skills/index.js +58 -0
  125. package/lib/analysis-skills/interests.js +161 -0
  126. package/lib/analysis-skills/relations.js +226 -0
  127. package/lib/analysis-skills/spending.js +219 -0
  128. package/lib/analysis-skills/timeline.js +167 -0
  129. package/lib/analysis.js +191 -2
  130. package/lib/entity-resolver/embedding-stage.js +198 -0
  131. package/lib/entity-resolver/entity-resolver.js +384 -0
  132. package/lib/entity-resolver/index.js +42 -0
  133. package/lib/entity-resolver/llm-stage.js +191 -0
  134. package/lib/entity-resolver/rule-stage.js +208 -0
  135. package/lib/entity-resolver/worker.js +149 -0
  136. package/lib/index.js +131 -0
  137. package/lib/migrations.js +73 -0
  138. package/lib/mobile-extractor/android.js +193 -0
  139. package/lib/mobile-extractor/index.js +9 -0
  140. package/lib/mobile-extractor/ios.js +223 -0
  141. package/lib/prompt-builder.js +11 -1
  142. package/lib/query-parser.js +7 -1
  143. package/lib/registry.js +42 -0
  144. package/lib/sidecar/index.js +15 -0
  145. package/lib/sidecar/supervisor.js +359 -0
  146. package/lib/vault.js +343 -0
  147. package/package.json +36 -3
  148. package/scripts/_make-fixture-all.js +126 -0
  149. package/scripts/_make-fixture-contacts.js +84 -0
  150. package/scripts/evaluate-entity-resolver.js +213 -0
  151. package/scripts/smoke-phase-5-5.js +196 -0
  152. package/scripts/smoke-phase-5-7.js +181 -0
  153. package/scripts/smoke-system-data-contacts.js +309 -0
  154. package/scripts/smoke-system-data.js +312 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Phase 13.6 — Telegram adapter.
3
+ *
4
+ * Per sjqz/parsers/telegram.py TelegramParser. Telegram Android stores
5
+ * messages in a single `cache4.db` (unencrypted SQLite!). Easier than
6
+ * WeChat — no key extraction needed.
7
+ *
8
+ * Tables of interest:
9
+ * - users contacts + groups
10
+ * - chats chat metadata
11
+ * - messages_v2 messages (multiple shards in newer versions)
12
+ * - dialogs chat ordering
13
+ */
14
+
15
+ "use strict";
16
+
17
+ const fs = require("node:fs");
18
+ const { newId } = require("../../ids");
19
+
20
+ const NAME = "messaging-telegram";
21
+ const VERSION = "0.5.0";
22
+
23
+ class TelegramAdapter {
24
+ constructor(opts = {}) {
25
+ if (!opts.account || !opts.account.userId) {
26
+ throw new Error("TelegramAdapter: opts.account.userId required");
27
+ }
28
+ this.account = opts.account;
29
+ this._dbPath = opts.dbPath || null;
30
+ this._dbDriverFactory = opts.dbDriverFactory || null;
31
+
32
+ this.name = NAME;
33
+ this.version = VERSION;
34
+ this.capabilities = ["sync:sqlite", "parse:telegram-messages"];
35
+ this.extractMode = "device-pull";
36
+ this.rateLimits = {};
37
+ this.dataDisclosure = {
38
+ fields: [
39
+ "telegram:users / chats / messages / dialogs",
40
+ ],
41
+ sensitivity: "high",
42
+ legalGate: true,
43
+ };
44
+ }
45
+
46
+ async authenticate() {
47
+ if (!this._dbPath || !fs.existsSync(this._dbPath)) {
48
+ return { ok: false, reason: "DB_NOT_PULLED" };
49
+ }
50
+ return { ok: true, account: this.account.userId };
51
+ }
52
+
53
+ async healthCheck() {
54
+ const r = await this.authenticate();
55
+ return r.ok ? { ok: true, lastChecked: Date.now() } : r;
56
+ }
57
+
58
+ async *sync(opts = {}) {
59
+ const dbPath = opts.dbPath || this._dbPath;
60
+ if (!dbPath || !fs.existsSync(dbPath)) return;
61
+ const Driver = this._dbDriverFactory
62
+ ? this._dbDriverFactory()
63
+ : require("better-sqlite3-multiple-ciphers");
64
+ const db = new Driver(dbPath, { readonly: true });
65
+ try {
66
+ const users = trySelect(db, "SELECT * FROM users LIMIT 5000") || [];
67
+ for (const row of users) {
68
+ yield { adapter: NAME, originalId: `user-${row.uid}`, capturedAt: Date.now(), payload: { row, kind: "contact" } };
69
+ }
70
+ const chats = trySelect(db, "SELECT * FROM chats LIMIT 5000") || [];
71
+ for (const row of chats) {
72
+ yield { adapter: NAME, originalId: `chat-${row.uid}`, capturedAt: Date.now(), payload: { row, kind: "chat" } };
73
+ }
74
+ const messages = trySelect(db, "SELECT * FROM messages_v2 ORDER BY date DESC LIMIT 10000")
75
+ || trySelect(db, "SELECT * FROM messages ORDER BY date DESC LIMIT 10000") || [];
76
+ for (const row of messages) {
77
+ yield { adapter: NAME, originalId: `msg-${row.mid || row.id}`, capturedAt: parseTime(row.date), payload: { row, kind: "message" } };
78
+ }
79
+ } finally {
80
+ try { db.close(); } catch (_e) {}
81
+ }
82
+ }
83
+
84
+ normalize(raw) {
85
+ const { kind, row } = raw.payload;
86
+ const now = Date.now();
87
+ const occurredAt = parseTime(row.date) || now;
88
+ const source = { adapter: NAME, adapterVersion: VERSION, originalId: raw.originalId, capturedAt: occurredAt, capturedBy: "sqlite" };
89
+
90
+ if (kind === "contact") {
91
+ return {
92
+ events: [], places: [], items: [], topics: [],
93
+ persons: [{
94
+ id: `person-telegram-${row.uid}`,
95
+ type: "person", subtype: "contact",
96
+ names: [row.name, row.username].filter((x) => typeof x === "string" && x.length > 0),
97
+ identifiers: { telegramId: String(row.uid), ...(row.phone ? { phone: [String(row.phone)] } : {}) },
98
+ ingestedAt: now, source,
99
+ extra: { fromAdapter: NAME, telegramUid: row.uid },
100
+ }],
101
+ };
102
+ }
103
+ if (kind === "chat") {
104
+ return {
105
+ events: [], places: [], items: [], persons: [],
106
+ topics: [{
107
+ id: `topic-telegram-${row.uid}`,
108
+ type: "topic", name: row.name || String(row.uid),
109
+ ingestedAt: now, source,
110
+ extra: { fromAdapter: NAME },
111
+ }],
112
+ };
113
+ }
114
+ // message
115
+ const isOutgoing = row.out === 1 || row.is_outgoing === 1;
116
+ return {
117
+ events: [{
118
+ id: newId(), type: "event", subtype: "message",
119
+ occurredAt,
120
+ actor: isOutgoing ? "person-self" : (row.from_id ? `person-telegram-${row.from_id}` : "person-self"),
121
+ content: { title: (row.message || "").slice(0, 80) || "(空)", text: row.message || "" },
122
+ ingestedAt: now, source,
123
+ extra: {
124
+ peer: row.uid || null,
125
+ isOutgoing,
126
+ mediaType: row.media_type || null,
127
+ },
128
+ }],
129
+ persons: [], places: [], items: [], topics: [],
130
+ };
131
+ }
132
+ }
133
+ function trySelect(db, sql) { try { return db.prepare(sql).all(); } catch (_e) { return null; } }
134
+ function parseTime(v) {
135
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
136
+ if (typeof v === "string") {
137
+ if (/^\d+$/.test(v)) { const n = parseInt(v, 10); return n > 1e12 ? n : n * 1000; }
138
+ return Date.parse(v) || null;
139
+ }
140
+ return null;
141
+ }
142
+ module.exports = { TelegramAdapter, NAME, VERSION };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Phase 13.7 — WhatsApp adapter.
3
+ *
4
+ * Per sjqz/parsers/whatsapp.py WhatsAppParser. WhatsApp Android stores
5
+ * messages in `msgstore.db` (encrypted with crypt14/crypt15 layered on
6
+ * SQLite). v0.5 accepts:
7
+ * 1. Decrypted msgstore.db at `dbPath` (user used WhatsApp Crypt
8
+ * Decrypter or similar tool first), or
9
+ * 2. Encrypted .crypt14 with `keyProvider` (Phase 13.7b adds the
10
+ * actual crypt14 → SQLite decoder; for now we error gracefully).
11
+ *
12
+ * Tables of interest:
13
+ * - jid contacts + chats (jid = WhatsApp ID)
14
+ * - chat chat metadata
15
+ * - message messages
16
+ * - call_log call records
17
+ */
18
+
19
+ "use strict";
20
+
21
+ const fs = require("node:fs");
22
+ const { newId } = require("../../ids");
23
+
24
+ const NAME = "messaging-whatsapp";
25
+ const VERSION = "0.5.0";
26
+
27
+ class WhatsAppAdapter {
28
+ constructor(opts = {}) {
29
+ if (!opts.account || !opts.account.phone) {
30
+ throw new Error("WhatsAppAdapter: opts.account.phone required");
31
+ }
32
+ this.account = opts.account;
33
+ this._dbPath = opts.dbPath || null;
34
+ this._keyProvider = opts.keyProvider || null;
35
+ this._dbDriverFactory = opts.dbDriverFactory || null;
36
+
37
+ this.name = NAME;
38
+ this.version = VERSION;
39
+ this.capabilities = ["sync:sqlite", "parse:whatsapp-messages"];
40
+ this.extractMode = "device-pull";
41
+ this.rateLimits = {};
42
+ this.dataDisclosure = {
43
+ fields: [
44
+ "whatsapp:jid (contacts + chats)",
45
+ "whatsapp:messages (text / media / time)",
46
+ "whatsapp:call_log",
47
+ ],
48
+ sensitivity: "high",
49
+ legalGate: true,
50
+ };
51
+ }
52
+
53
+ async authenticate() {
54
+ if (!this._dbPath || !fs.existsSync(this._dbPath)) {
55
+ return { ok: false, reason: "DB_NOT_PULLED" };
56
+ }
57
+ return { ok: true, account: this.account.phone };
58
+ }
59
+
60
+ async healthCheck() {
61
+ const r = await this.authenticate();
62
+ return r.ok ? { ok: true, lastChecked: Date.now() } : r;
63
+ }
64
+
65
+ async *sync(opts = {}) {
66
+ const dbPath = opts.dbPath || this._dbPath;
67
+ if (!dbPath || !fs.existsSync(dbPath)) return;
68
+ const Driver = this._dbDriverFactory
69
+ ? this._dbDriverFactory()
70
+ : require("better-sqlite3-multiple-ciphers");
71
+ const db = new Driver(dbPath, { readonly: true });
72
+ try {
73
+ const jids = trySelect(db, "SELECT * FROM jid LIMIT 5000") || [];
74
+ for (const row of jids) {
75
+ yield { adapter: NAME, originalId: `jid-${row._id}`, capturedAt: Date.now(), payload: { row, kind: "contact" } };
76
+ }
77
+ const chats = trySelect(db, "SELECT * FROM chat LIMIT 1000") || [];
78
+ for (const row of chats) {
79
+ yield { adapter: NAME, originalId: `chat-${row._id}`, capturedAt: Date.now(), payload: { row, kind: "chat" } };
80
+ }
81
+ const messages = trySelect(db, "SELECT * FROM message ORDER BY timestamp DESC LIMIT 10000")
82
+ || trySelect(db, "SELECT * FROM messages ORDER BY timestamp DESC LIMIT 10000") || [];
83
+ for (const row of messages) {
84
+ yield { adapter: NAME, originalId: `msg-${row._id}`, capturedAt: parseTime(row.timestamp || row.received_timestamp), payload: { row, kind: "message" } };
85
+ }
86
+ const calls = trySelect(db, "SELECT * FROM call_log ORDER BY timestamp DESC LIMIT 5000") || [];
87
+ for (const row of calls) {
88
+ yield { adapter: NAME, originalId: `call-${row._id}`, capturedAt: parseTime(row.timestamp), payload: { row, kind: "call" } };
89
+ }
90
+ } finally {
91
+ try { db.close(); } catch (_e) {}
92
+ }
93
+ }
94
+
95
+ normalize(raw) {
96
+ const { kind, row } = raw.payload;
97
+ const now = Date.now();
98
+ const occurredAt = parseTime(row.timestamp || row.received_timestamp) || now;
99
+ const source = { adapter: NAME, adapterVersion: VERSION, originalId: raw.originalId, capturedAt: occurredAt, capturedBy: "sqlite" };
100
+
101
+ if (kind === "contact") {
102
+ // WhatsApp jids are "<phone>@s.whatsapp.net" or "<phone>@g.us" (group)
103
+ const isGroup = typeof row.raw_string === "string" && row.raw_string.includes("@g.us");
104
+ if (isGroup) {
105
+ return {
106
+ events: [], places: [], items: [], persons: [],
107
+ topics: [{
108
+ id: `topic-whatsapp-${row.raw_string}`,
109
+ type: "topic", name: row.display_name || row.raw_string,
110
+ ingestedAt: now, source,
111
+ extra: { fromAdapter: NAME, jid: row.raw_string },
112
+ }],
113
+ };
114
+ }
115
+ const phone = (row.user || "").replace(/[^0-9]/g, "");
116
+ return {
117
+ events: [], places: [], items: [], topics: [],
118
+ persons: [{
119
+ id: `person-whatsapp-${row.user || row._id}`,
120
+ type: "person", subtype: "contact",
121
+ names: [row.display_name, row.user].filter((x) => typeof x === "string" && x.length > 0),
122
+ identifiers: phone ? { phone: [phone] } : {},
123
+ ingestedAt: now, source,
124
+ extra: { fromAdapter: NAME, jid: row.raw_string },
125
+ }],
126
+ };
127
+ }
128
+
129
+ if (kind === "chat") {
130
+ return {
131
+ events: [], places: [], items: [], persons: [],
132
+ topics: [{
133
+ id: `topic-whatsapp-chat-${row._id}`,
134
+ type: "topic", name: row.subject || row.display_name || String(row._id),
135
+ ingestedAt: now, source,
136
+ extra: { fromAdapter: NAME },
137
+ }],
138
+ };
139
+ }
140
+
141
+ if (kind === "call") {
142
+ return {
143
+ events: [{
144
+ id: newId(), type: "event", subtype: "call",
145
+ occurredAt, actor: row.from_me ? "person-self" : (row.jid_row_id ? `person-whatsapp-${row.jid_row_id}` : "person-self"),
146
+ content: { title: `WhatsApp call (${row.video_call ? "video" : "voice"})` },
147
+ ingestedAt: now, source,
148
+ extra: {
149
+ duration: row.duration || null,
150
+ isVideo: !!row.video_call,
151
+ fromMe: !!row.from_me,
152
+ callResult: row.call_result || null,
153
+ },
154
+ }],
155
+ persons: [], places: [], items: [], topics: [],
156
+ };
157
+ }
158
+
159
+ // message
160
+ return {
161
+ events: [{
162
+ id: newId(), type: "event", subtype: "message",
163
+ occurredAt,
164
+ actor: row.from_me === 1 ? "person-self" : (row.key_remote_jid ? `person-whatsapp-${row.key_remote_jid}` : "person-self"),
165
+ content: { title: (row.text_data || row.data || "").slice(0, 80) || "(空)", text: row.text_data || row.data || "" },
166
+ ingestedAt: now, source,
167
+ extra: {
168
+ jid: row.key_remote_jid || row.from_jid_row_id || null,
169
+ isOutgoing: row.from_me === 1,
170
+ mediaType: row.media_wa_type || null,
171
+ status: row.status || null,
172
+ },
173
+ }],
174
+ persons: [], places: [], items: [], topics: [],
175
+ };
176
+ }
177
+ }
178
+
179
+ function trySelect(db, sql) { try { return db.prepare(sql).all(); } catch (_e) { return null; } }
180
+ function parseTime(v) {
181
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
182
+ if (typeof v === "string") {
183
+ if (/^\d+$/.test(v)) { const n = parseInt(v, 10); return n > 1e12 ? n : n * 1000; }
184
+ return Date.parse(v) || null;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ module.exports = { WhatsAppAdapter, NAME, VERSION };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Phase 7 — shared shopping adapter base.
3
+ *
4
+ * Common normalize logic for Taobao / JD / Meituan (and future Pinduoduo,
5
+ * Xiaohongshu, etc.) Each per-platform adapter parses platform-specific
6
+ * JSON into a `OrderRecord` then calls `normalizeOrderRecord()` here.
7
+ *
8
+ * OrderRecord shape (vendor-neutral):
9
+ * {
10
+ * vendorId: "taobao" | "jd" | "meituan" | ...
11
+ * orderId: string (vendor's order id, unique)
12
+ * placedAt: ms epoch
13
+ * paidAt?: ms epoch
14
+ * status: "placed" | "shipped" | "delivered" | "refunded" | "cancelled"
15
+ * merchantName: string
16
+ * totalAmount: { value, currency }
17
+ * items: [{ name, quantity, unitPrice, sku? }]
18
+ * recipient?: string
19
+ * shippingAddress?: string
20
+ * trackingNumber?: string
21
+ * coupons?: { value, currency }
22
+ * extras?: { ... vendor-specific }
23
+ * }
24
+ *
25
+ * Output: Event(subtype="order"|"payment"|"refund") + merchant Person
26
+ * + Item entities (per ordered SKU).
27
+ */
28
+
29
+ "use strict";
30
+
31
+ const { newId } = require("../../ids");
32
+
33
+ /**
34
+ * @param {OrderRecord} rec
35
+ * @param {object} ctx { adapterName, adapterVersion }
36
+ * @returns {NormalizedBatch}
37
+ */
38
+ function normalizeOrderRecord(rec, ctx = {}) {
39
+ if (!rec || typeof rec !== "object") {
40
+ throw new Error("normalizeOrderRecord: rec required");
41
+ }
42
+ if (!rec.orderId) throw new Error("normalizeOrderRecord: rec.orderId required");
43
+ if (!rec.merchantName) throw new Error("normalizeOrderRecord: rec.merchantName required");
44
+
45
+ const now = Date.now();
46
+ const occurredAt = Number.isFinite(rec.placedAt) ? rec.placedAt : now;
47
+ const adapterName = ctx.adapterName || rec.vendorId || "shopping";
48
+ const adapterVersion = ctx.adapterVersion || "0.1.0";
49
+
50
+ const source = {
51
+ adapter: adapterName,
52
+ adapterVersion,
53
+ originalId: String(rec.orderId),
54
+ capturedAt: occurredAt,
55
+ capturedBy: "api",
56
+ };
57
+
58
+ // Merchant Person
59
+ const merchantId = `person-${adapterName}-merchant-${slug(rec.merchantName)}`;
60
+ const persons = [{
61
+ id: merchantId,
62
+ type: "person",
63
+ subtype: "merchant",
64
+ names: [rec.merchantName],
65
+ identifiers: {},
66
+ ingestedAt: now,
67
+ source,
68
+ extra: { fromAdapter: adapterName, merchant: true },
69
+ }];
70
+
71
+ // Items
72
+ const items = [];
73
+ if (Array.isArray(rec.items)) {
74
+ for (const it of rec.items) {
75
+ if (!it || !it.name) continue;
76
+ items.push({
77
+ id: newId(),
78
+ type: "item",
79
+ subtype: "product",
80
+ name: it.name,
81
+ merchant: merchantId,
82
+ price: it.unitPrice != null
83
+ ? { value: Number(it.unitPrice) || 0, currency: rec.totalAmount?.currency || "CNY" }
84
+ : null,
85
+ ingestedAt: now,
86
+ source,
87
+ extra: {
88
+ quantity: it.quantity || 1,
89
+ sku: it.sku || null,
90
+ fromAdapter: adapterName,
91
+ },
92
+ });
93
+ }
94
+ }
95
+
96
+ // Event
97
+ const subtype = mapStatusToSubtype(rec.status);
98
+ const eventId = newId();
99
+ const event = {
100
+ id: eventId,
101
+ type: "event",
102
+ subtype,
103
+ occurredAt,
104
+ actor: "person-self",
105
+ participants: ["person-self", merchantId],
106
+ content: {
107
+ title: `${rec.merchantName} 订单 ${rec.orderId}`,
108
+ ...(rec.totalAmount && Number.isFinite(rec.totalAmount.value)
109
+ ? { amount: { value: rec.totalAmount.value, currency: rec.totalAmount.currency || "CNY", direction: subtype === "refund" ? "in" : "out" } }
110
+ : {}),
111
+ ...(items.length > 0 ? { text: items.map((i) => `${i.name} x${i.extra.quantity || 1}`).join("; ") } : {}),
112
+ },
113
+ ingestedAt: now,
114
+ source,
115
+ extra: {
116
+ vendorId: rec.vendorId,
117
+ orderId: rec.orderId,
118
+ merchantName: rec.merchantName,
119
+ merchantOrderNumber: rec.orderId, // cross-source link to Email + Alipay
120
+ orderStatus: rec.status || "placed",
121
+ itemCount: items.length,
122
+ ...(rec.recipient ? { recipient: rec.recipient } : {}),
123
+ ...(rec.shippingAddress ? { shippingAddress: rec.shippingAddress } : {}),
124
+ ...(rec.trackingNumber ? { trackingNumber: rec.trackingNumber } : {}),
125
+ ...(rec.coupons ? { coupons: rec.coupons } : {}),
126
+ ...(rec.paidAt ? { paidAt: rec.paidAt } : {}),
127
+ ...(rec.extras ? { vendorExtras: rec.extras } : {}),
128
+ },
129
+ };
130
+
131
+ return { events: [event], persons, places: [], items, topics: [] };
132
+ }
133
+
134
+ function mapStatusToSubtype(status) {
135
+ const s = String(status || "").toLowerCase();
136
+ if (s.includes("refund") || s.includes("退款")) return "refund";
137
+ if (s.includes("cancel") || s.includes("close") || s.includes("已取消") || s.includes("已关闭")) return "cancelled";
138
+ // All other order states ("placed", "shipped", "delivered") map to
139
+ // `order` subtype — the lifecycle status is in extra.orderStatus.
140
+ return "order";
141
+ }
142
+
143
+ function slug(s) {
144
+ return String(s || "")
145
+ .toLowerCase()
146
+ .replace(/\s+/g, "-")
147
+ .replace(/[^\w一-鿿-]/g, "")
148
+ .slice(0, 80);
149
+ }
150
+
151
+ // ─── CookieAuth helper ──────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Cookie storage + validation helper. Each platform adapter constructs
155
+ * one and uses validate() before sync.
156
+ */
157
+ class CookieAuth {
158
+ constructor(opts = {}) {
159
+ if (!opts.platform) throw new Error("CookieAuth: opts.platform required");
160
+ this.platform = opts.platform;
161
+ this.cookies = opts.cookies || ""; // raw "k=v; k2=v2" string
162
+ this._validator = opts.validator || null; // optional async fn(cookies) → boolean
163
+ }
164
+
165
+ setCookies(raw) {
166
+ if (typeof raw !== "string") throw new Error("setCookies: string required");
167
+ this.cookies = raw;
168
+ }
169
+
170
+ /**
171
+ * Get the cookie string for adding to Headers. Returns null when
172
+ * cookies are empty.
173
+ */
174
+ toHeader() {
175
+ return this.cookies && this.cookies.length > 0 ? this.cookies : null;
176
+ }
177
+
178
+ /**
179
+ * Validate that the stored cookies are still good. Without an
180
+ * injected validator returns true if non-empty (caller decides
181
+ * whether to probe the platform).
182
+ */
183
+ async validate() {
184
+ if (!this.cookies) return false;
185
+ if (this._validator) return await this._validator(this.cookies);
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * Read a specific cookie value by name (case-insensitive).
191
+ */
192
+ getCookieValue(name) {
193
+ if (!this.cookies || !name) return null;
194
+ const re = new RegExp(`(?:^|;\\s*)${escapeRegex(name)}=([^;]*)`, "i");
195
+ const m = re.exec(this.cookies);
196
+ return m ? decodeURIComponent(m[1]) : null;
197
+ }
198
+ }
199
+
200
+ function escapeRegex(s) {
201
+ return String(s).replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
202
+ }
203
+
204
+ module.exports = {
205
+ normalizeOrderRecord,
206
+ mapStatusToSubtype,
207
+ CookieAuth,
208
+ };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Phase 7.3 — JD (京东) adapter.
3
+ *
4
+ * Parallels Taobao but with JD's order endpoint + JSON shape:
5
+ * url: https://order.jd.com/center/list.action
6
+ * fields: orderId, orderTotalPrice, orderStartTime, orderStatusText,
7
+ * venderName, productList (with name/quantity/price)
8
+ */
9
+
10
+ "use strict";
11
+
12
+ const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
13
+
14
+ const NAME = "shopping-jd";
15
+ const VERSION = "0.5.0";
16
+
17
+ const JD_ORDERS_URL = "https://order.jd.com/center/list.action";
18
+
19
+ class JdAdapter {
20
+ constructor(opts = {}) {
21
+ if (!opts.account || !opts.account.pin) {
22
+ throw new Error("JdAdapter: opts.account.pin required (JD user pin)");
23
+ }
24
+ this.account = opts.account;
25
+ this._cookieAuth = new CookieAuth({
26
+ platform: "jd",
27
+ cookies: opts.account.cookies || "",
28
+ });
29
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
30
+
31
+ this.name = NAME;
32
+ this.version = VERSION;
33
+ this.capabilities = ["sync:cookie-api", "parse:jd-orders"];
34
+ this.extractMode = "web-api";
35
+ this.rateLimits = { perMinute: 6, perDay: 200 };
36
+ this.dataDisclosure = {
37
+ fields: ["jd:orderId / venderName / productList / orderTotalPrice / address"],
38
+ sensitivity: "high",
39
+ legalGate: false,
40
+ };
41
+ }
42
+
43
+ async authenticate() {
44
+ const ok = await this._cookieAuth.validate();
45
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
46
+ return { ok: true, account: this.account.pin };
47
+ }
48
+
49
+ async healthCheck() {
50
+ const r = await this.authenticate();
51
+ return r.ok
52
+ ? { ok: true, lastChecked: Date.now() }
53
+ : { ok: false, reason: r.reason, error: r.error };
54
+ }
55
+
56
+ async *sync(opts = {}) {
57
+ if (!(await this._cookieAuth.validate())) return;
58
+ const sinceMs = opts.sinceWatermark != null
59
+ ? parseInt(String(opts.sinceWatermark), 10) || 0
60
+ : (Date.now() - 365 * 24 * 3600_000);
61
+ let page = 1;
62
+ while (true) {
63
+ const resp = await this._fetchFn({
64
+ url: JD_ORDERS_URL,
65
+ cookies: this._cookieAuth.toHeader(),
66
+ query: { page },
67
+ });
68
+ if (!resp || !Array.isArray(resp.orders)) break;
69
+ let pageHasNew = false;
70
+ for (const raw of resp.orders) {
71
+ const rec = orderToRecord(raw);
72
+ if (!rec) continue;
73
+ if (rec.placedAt && rec.placedAt < sinceMs) break;
74
+ pageHasNew = true;
75
+ yield {
76
+ adapter: NAME,
77
+ originalId: rec.orderId,
78
+ capturedAt: rec.placedAt || Date.now(),
79
+ payload: { record: rec },
80
+ };
81
+ }
82
+ if (!pageHasNew || resp.orders.length < 10) break;
83
+ page += 1;
84
+ }
85
+ }
86
+
87
+ normalize(raw) {
88
+ return normalizeOrderRecord(raw.payload.record, {
89
+ adapterName: NAME, adapterVersion: VERSION,
90
+ });
91
+ }
92
+ }
93
+
94
+ function orderToRecord(o) {
95
+ if (!o || typeof o !== "object") return null;
96
+ const orderId = o.orderId || o.id;
97
+ const merchant = o.venderName || o.shopName || o.merchantName || "京东自营";
98
+ if (!orderId) return null;
99
+
100
+ const items = [];
101
+ const products = o.productList || o.products || [];
102
+ for (const it of products) {
103
+ if (!it) continue;
104
+ items.push({
105
+ name: it.productName || it.skuName || it.name,
106
+ quantity: parseInt(it.productQuantity || it.quantity || 1, 10),
107
+ unitPrice: parseFloat(it.productPrice || it.unitPrice || 0),
108
+ sku: it.skuId || it.sku || null,
109
+ });
110
+ }
111
+
112
+ return {
113
+ vendorId: "jd",
114
+ orderId: String(orderId),
115
+ placedAt: parseTime(o.orderStartTime || o.orderTime || o.createTime),
116
+ paidAt: parseTime(o.paymentTime || o.payTime),
117
+ status: mapStatus(o.orderStatusText || o.statusName || o.status),
118
+ merchantName: merchant,
119
+ totalAmount: { value: parseFloat(o.orderTotalPrice || o.totalPrice || 0), currency: "CNY" },
120
+ items,
121
+ recipient: o.consigneeName || o.receiverName,
122
+ shippingAddress: o.address || o.consigneeAddress,
123
+ trackingNumber: o.shipmentNo || o.trackingNumber,
124
+ extras: { rawStatus: o.orderStatusText || o.statusName },
125
+ };
126
+ }
127
+
128
+ function parseTime(v) {
129
+ if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
130
+ if (typeof v === "string") {
131
+ const t = Date.parse(v);
132
+ if (Number.isFinite(t)) return t;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function mapStatus(s) {
138
+ const t = String(s || "").toLowerCase();
139
+ if (t.includes("退款") || t.includes("refund")) return "refunded";
140
+ if (t.includes("取消") || t.includes("cancel")) return "cancelled";
141
+ if (t.includes("已发货") || t.includes("配送") || t.includes("shipped")) return "shipped";
142
+ if (t.includes("已完成") || t.includes("已收货") || t.includes("delivered")) return "delivered";
143
+ return "placed";
144
+ }
145
+
146
+ async function defaultFetch(_opts) {
147
+ throw new Error("JdAdapter: no fetchFn configured");
148
+ }
149
+
150
+ module.exports = { JdAdapter, orderToRecord, NAME, VERSION };