@chainlesschain/personal-data-hub 0.1.0 → 0.2.0
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/ai-chat-history.test.js +395 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/analysis-skills.test.js +409 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +221 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/index.js +28 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +216 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +115 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +266 -0
- package/package.json +29 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 13.2 — Weibo (微博) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Source: Weibo Android app SQLite DBs (per sjqz/parsers/social.py
|
|
5
|
+
* WeiboParser). Three tables of v0 interest:
|
|
6
|
+
* - post / status posts the user published
|
|
7
|
+
* - search_history queries
|
|
8
|
+
* - message / direct private messages
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const { newId } = require("../../ids");
|
|
15
|
+
|
|
16
|
+
const NAME = "social-weibo";
|
|
17
|
+
const VERSION = "0.5.0";
|
|
18
|
+
|
|
19
|
+
class WeiboAdapter {
|
|
20
|
+
constructor(opts = {}) {
|
|
21
|
+
if (!opts.account || !opts.account.uid) {
|
|
22
|
+
throw new Error("WeiboAdapter: opts.account.uid required");
|
|
23
|
+
}
|
|
24
|
+
this.account = opts.account;
|
|
25
|
+
this._dbPath = opts.dbPath || null;
|
|
26
|
+
this._dbDriverFactory = opts.dbDriverFactory || null;
|
|
27
|
+
|
|
28
|
+
this.name = NAME;
|
|
29
|
+
this.version = VERSION;
|
|
30
|
+
this.capabilities = ["sync:sqlite", "parse:weibo-posts", "parse:weibo-search"];
|
|
31
|
+
this.extractMode = "device-pull";
|
|
32
|
+
this.rateLimits = {};
|
|
33
|
+
this.dataDisclosure = {
|
|
34
|
+
fields: [
|
|
35
|
+
"weibo:posts (text / created_at / reposts_count / comments_count)",
|
|
36
|
+
"weibo:search_history",
|
|
37
|
+
"weibo:messages",
|
|
38
|
+
],
|
|
39
|
+
sensitivity: "medium",
|
|
40
|
+
legalGate: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async authenticate() {
|
|
45
|
+
return { ok: true, account: this.account.uid };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async healthCheck() {
|
|
49
|
+
return { ok: true, lastChecked: Date.now() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async *sync(opts = {}) {
|
|
53
|
+
const dbPath = opts.dbPath || this._dbPath;
|
|
54
|
+
if (!dbPath || !fs.existsSync(dbPath)) return;
|
|
55
|
+
const Driver = this._dbDriverFactory
|
|
56
|
+
? this._dbDriverFactory()
|
|
57
|
+
: require("better-sqlite3-multiple-ciphers");
|
|
58
|
+
const db = new Driver(dbPath, { readonly: true });
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const posts = trySelect(db, "SELECT * FROM post ORDER BY created_at DESC LIMIT 5000")
|
|
62
|
+
|| trySelect(db, "SELECT * FROM status ORDER BY created_at DESC LIMIT 5000") || [];
|
|
63
|
+
for (const row of posts) {
|
|
64
|
+
yield {
|
|
65
|
+
adapter: NAME,
|
|
66
|
+
originalId: `post-${row.id || row.mid || row.idstr}`,
|
|
67
|
+
capturedAt: parseTime(row.created_at || row.time),
|
|
68
|
+
payload: { row, kind: "post" },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const searches = trySelect(db, "SELECT * FROM search_history ORDER BY time DESC LIMIT 5000") || [];
|
|
73
|
+
for (const row of searches) {
|
|
74
|
+
yield {
|
|
75
|
+
adapter: NAME,
|
|
76
|
+
originalId: `search-${row.id || row._id}`,
|
|
77
|
+
capturedAt: parseTime(row.time || row.create_at),
|
|
78
|
+
payload: { row, kind: "search" },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
try { db.close(); } catch (_e) {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
normalize(raw) {
|
|
87
|
+
if (!raw || !raw.payload || !raw.payload.row) {
|
|
88
|
+
throw new Error("WeiboAdapter.normalize: row missing");
|
|
89
|
+
}
|
|
90
|
+
const { kind, row } = raw.payload;
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const occurredAt = parseTime(row.created_at || row.time) || now;
|
|
93
|
+
const source = {
|
|
94
|
+
adapter: NAME, adapterVersion: VERSION,
|
|
95
|
+
originalId: raw.originalId, capturedAt: occurredAt,
|
|
96
|
+
capturedBy: "sqlite",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (kind === "search") {
|
|
100
|
+
return {
|
|
101
|
+
events: [{
|
|
102
|
+
id: newId(),
|
|
103
|
+
type: "event",
|
|
104
|
+
subtype: "interaction",
|
|
105
|
+
occurredAt,
|
|
106
|
+
actor: "person-self",
|
|
107
|
+
content: {
|
|
108
|
+
title: `搜索: ${row.keyword || row.query || ""}`,
|
|
109
|
+
text: row.keyword || row.query || "",
|
|
110
|
+
},
|
|
111
|
+
ingestedAt: now,
|
|
112
|
+
source,
|
|
113
|
+
extra: { query: row.keyword || row.query, fromAdapter: NAME },
|
|
114
|
+
}],
|
|
115
|
+
persons: [], places: [], items: [], topics: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Post
|
|
120
|
+
return {
|
|
121
|
+
events: [{
|
|
122
|
+
id: newId(),
|
|
123
|
+
type: "event",
|
|
124
|
+
subtype: "post",
|
|
125
|
+
occurredAt,
|
|
126
|
+
actor: "person-self",
|
|
127
|
+
content: {
|
|
128
|
+
title: (row.text || "").slice(0, 80) || "(空)",
|
|
129
|
+
text: row.text || "",
|
|
130
|
+
},
|
|
131
|
+
ingestedAt: now,
|
|
132
|
+
source,
|
|
133
|
+
extra: {
|
|
134
|
+
weiboMid: row.mid || row.id || row.idstr || null,
|
|
135
|
+
repostsCount: row.reposts_count || row.repost || 0,
|
|
136
|
+
commentsCount: row.comments_count || row.comments || 0,
|
|
137
|
+
likesCount: row.attitudes_count || row.likes || 0,
|
|
138
|
+
source: row.source || null, // 客户端
|
|
139
|
+
location: row.location || row.geo || null,
|
|
140
|
+
},
|
|
141
|
+
}],
|
|
142
|
+
persons: [], places: [], items: [], topics: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function trySelect(db, sql) {
|
|
148
|
+
try { return db.prepare(sql).all(); } catch (_e) { return null; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseTime(v) {
|
|
152
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
153
|
+
if (typeof v === "string") {
|
|
154
|
+
if (/^\d+$/.test(v)) {
|
|
155
|
+
const n = parseInt(v, 10);
|
|
156
|
+
return n > 1e12 ? n : n * 1000;
|
|
157
|
+
}
|
|
158
|
+
const t = Date.parse(v);
|
|
159
|
+
return Number.isFinite(t) ? t : null;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { WeiboAdapter, NAME, VERSION };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 13.4 — Xiaohongshu (小红书) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Per sjqz/parsers/lifestyle.py XiaohongshuParser. Tables:
|
|
5
|
+
* - note / browse_history viewed notes
|
|
6
|
+
* - liked_note / favourite collected notes
|
|
7
|
+
* - search_history queries
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
const { newId } = require("../../ids");
|
|
14
|
+
|
|
15
|
+
const NAME = "social-xiaohongshu";
|
|
16
|
+
const VERSION = "0.5.0";
|
|
17
|
+
|
|
18
|
+
class XiaohongshuAdapter {
|
|
19
|
+
constructor(opts = {}) {
|
|
20
|
+
if (!opts.account || !opts.account.uid) {
|
|
21
|
+
throw new Error("XiaohongshuAdapter: opts.account.uid required");
|
|
22
|
+
}
|
|
23
|
+
this.account = opts.account;
|
|
24
|
+
this._dbPath = opts.dbPath || null;
|
|
25
|
+
this._dbDriverFactory = opts.dbDriverFactory || null;
|
|
26
|
+
|
|
27
|
+
this.name = NAME;
|
|
28
|
+
this.version = VERSION;
|
|
29
|
+
this.capabilities = ["sync:sqlite", "parse:xhs-history"];
|
|
30
|
+
this.extractMode = "device-pull";
|
|
31
|
+
this.rateLimits = {};
|
|
32
|
+
this.dataDisclosure = {
|
|
33
|
+
fields: ["xhs:viewed_notes / liked / favourites / search_history"],
|
|
34
|
+
sensitivity: "medium",
|
|
35
|
+
legalGate: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async authenticate() { return { ok: true, account: this.account.uid }; }
|
|
40
|
+
async healthCheck() { return { ok: true, lastChecked: Date.now() }; }
|
|
41
|
+
|
|
42
|
+
async *sync(opts = {}) {
|
|
43
|
+
const dbPath = opts.dbPath || this._dbPath;
|
|
44
|
+
if (!dbPath || !fs.existsSync(dbPath)) return;
|
|
45
|
+
const Driver = this._dbDriverFactory
|
|
46
|
+
? this._dbDriverFactory()
|
|
47
|
+
: require("better-sqlite3-multiple-ciphers");
|
|
48
|
+
const db = new Driver(dbPath, { readonly: true });
|
|
49
|
+
try {
|
|
50
|
+
const histories = trySelect(db, "SELECT * FROM browse_history ORDER BY view_time DESC LIMIT 5000")
|
|
51
|
+
|| trySelect(db, "SELECT * FROM note ORDER BY view_time DESC LIMIT 5000") || [];
|
|
52
|
+
for (const row of histories) {
|
|
53
|
+
yield { adapter: NAME, originalId: `history-${row.id || row.note_id}`, capturedAt: parseTime(row.view_time), payload: { row, kind: "history" } };
|
|
54
|
+
}
|
|
55
|
+
const likes = trySelect(db, "SELECT * FROM liked_note ORDER BY like_time DESC LIMIT 5000") || [];
|
|
56
|
+
for (const row of likes) {
|
|
57
|
+
yield { adapter: NAME, originalId: `like-${row.id || row.note_id}`, capturedAt: parseTime(row.like_time), payload: { row, kind: "like" } };
|
|
58
|
+
}
|
|
59
|
+
const favs = trySelect(db, "SELECT * FROM favourite ORDER BY save_time DESC LIMIT 5000") || [];
|
|
60
|
+
for (const row of favs) {
|
|
61
|
+
yield { adapter: NAME, originalId: `fav-${row.id || row.note_id}`, capturedAt: parseTime(row.save_time), payload: { row, kind: "favourite" } };
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
try { db.close(); } catch (_e) {}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
normalize(raw) {
|
|
69
|
+
const { kind, row } = raw.payload;
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const occurredAt = parseTime(row.view_time || row.like_time || row.save_time) || now;
|
|
72
|
+
const source = { adapter: NAME, adapterVersion: VERSION, originalId: raw.originalId, capturedAt: occurredAt, capturedBy: "sqlite" };
|
|
73
|
+
const subtypeMap = { history: "browse", like: "like", favourite: "like" };
|
|
74
|
+
return {
|
|
75
|
+
events: [{
|
|
76
|
+
id: newId(), type: "event",
|
|
77
|
+
subtype: subtypeMap[kind] || "browse",
|
|
78
|
+
occurredAt, actor: "person-self",
|
|
79
|
+
content: { title: row.title || row.note_title || "(no title)" },
|
|
80
|
+
ingestedAt: now, source,
|
|
81
|
+
extra: { noteId: row.note_id || null, author: row.author || row.nickname || null, kind },
|
|
82
|
+
}],
|
|
83
|
+
persons: [], places: [], items: [], topics: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function trySelect(db, sql) { try { return db.prepare(sql).all(); } catch (_e) { return null; } }
|
|
88
|
+
function parseTime(v) {
|
|
89
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
90
|
+
if (typeof v === "string") {
|
|
91
|
+
if (/^\d+$/.test(v)) { const n = parseInt(v, 10); return n > 1e12 ? n : n * 1000; }
|
|
92
|
+
const t = Date.parse(v); return Number.isFinite(t) ? t : null;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
module.exports = { XiaohongshuAdapter, NAME, VERSION };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dataDisclosure metadata + helpers for SystemDataAdapter — Phase 4.5.6.
|
|
3
|
+
*
|
|
4
|
+
* The metadata itself lives on the adapter (so the AdapterRegistry sees it
|
|
5
|
+
* during `assertAdapter`). This module exposes additional helpers for the
|
|
6
|
+
* desktop UI / hub layer to read:
|
|
7
|
+
*
|
|
8
|
+
* - SOURCE_DESCRIPTORS — per-source human-readable label + field list +
|
|
9
|
+
* sensitivity tier; the UI maps each to a toggle in the disclosure
|
|
10
|
+
* dialog (per Adapter_System_Data.md §5.1 mockup).
|
|
11
|
+
* - sanitizeInclude({...}) — clamp arbitrary user input to the supported
|
|
12
|
+
* boolean shape, so a stale Vue store can't smuggle non-boolean values
|
|
13
|
+
* past adapter.sync().
|
|
14
|
+
* - resolveRetentionMs({retentionDays}) — convert the user-set retention
|
|
15
|
+
* policy to milliseconds; the hub job runner uses this to schedule
|
|
16
|
+
* periodic deletes.
|
|
17
|
+
*
|
|
18
|
+
* Why a separate file rather than inline on the adapter? Three reasons:
|
|
19
|
+
* 1. The Vue layer can `require()` this without spinning up a SidecarSupervisor.
|
|
20
|
+
* 2. Tests can compare the descriptor list against the adapter's actual
|
|
21
|
+
* `dataDisclosure.fields` array, catching drift.
|
|
22
|
+
* 3. Future sources (calendar, browser history) follow the same shape so
|
|
23
|
+
* the disclosure UI is reusable.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
"use strict";
|
|
27
|
+
|
|
28
|
+
const SOURCE_DESCRIPTORS = Object.freeze({
|
|
29
|
+
contacts: Object.freeze({
|
|
30
|
+
key: "contacts",
|
|
31
|
+
label: "通讯录",
|
|
32
|
+
fields: ["name", "phone", "email", "organization", "notes", "starred", "photoUri"],
|
|
33
|
+
sensitivity: "medium",
|
|
34
|
+
defaultEnabled: true,
|
|
35
|
+
estimate: "约 50-500 人",
|
|
36
|
+
rationale: "作为 EntityResolver 跨源 Person 主键的权威种子集",
|
|
37
|
+
}),
|
|
38
|
+
calllog: Object.freeze({
|
|
39
|
+
key: "calllog",
|
|
40
|
+
label: "通话记录",
|
|
41
|
+
fields: ["number", "duration", "timestamp", "type", "name"],
|
|
42
|
+
sensitivity: "high",
|
|
43
|
+
defaultEnabled: true,
|
|
44
|
+
estimate: "近 1 年约 1-10k 条",
|
|
45
|
+
rationale: "跨人互动时间线,独立于 app 内聊天",
|
|
46
|
+
}),
|
|
47
|
+
sms: Object.freeze({
|
|
48
|
+
key: "sms",
|
|
49
|
+
label: "短信和彩信",
|
|
50
|
+
fields: ["address", "body", "timestamp", "type", "threadId", "isRead"],
|
|
51
|
+
sensitivity: "high",
|
|
52
|
+
defaultEnabled: false, // opt-out (per OQ-SD1 + design doc §5.1)
|
|
53
|
+
estimate: "近 3 年约 5-20k 条",
|
|
54
|
+
rationale: "银行账单、验证码、物流通知的元数据源;含他人信息",
|
|
55
|
+
warning: "可能包含他人电话号码或对话内容",
|
|
56
|
+
}),
|
|
57
|
+
wifi: Object.freeze({
|
|
58
|
+
key: "wifi",
|
|
59
|
+
label: "WiFi 网络(不含密码)",
|
|
60
|
+
fields: ["ssid", "securityType", "hidden"],
|
|
61
|
+
excludedFields: ["password"],
|
|
62
|
+
sensitivity: "low",
|
|
63
|
+
defaultEnabled: true,
|
|
64
|
+
estimate: "约 5-50 个",
|
|
65
|
+
rationale: "常去地点种子(家/办公室/常去咖啡店)",
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const SOURCE_KEYS = Object.freeze(Object.keys(SOURCE_DESCRIPTORS));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clamp arbitrary user-provided `include` object to a strict boolean shape.
|
|
73
|
+
* Unknown keys are dropped; missing keys fall back to descriptor defaults.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} raw user-controllable input from UI / IPC layer
|
|
76
|
+
* @returns {{contacts: boolean, calllog: boolean, sms: boolean, wifi: boolean}}
|
|
77
|
+
*/
|
|
78
|
+
function sanitizeInclude(raw) {
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const key of SOURCE_KEYS) {
|
|
81
|
+
if (raw && Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
82
|
+
out[key] = Boolean(raw[key]);
|
|
83
|
+
} else {
|
|
84
|
+
out[key] = SOURCE_DESCRIPTORS[key].defaultEnabled;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a user-configured retention policy to a wall-clock millisecond
|
|
92
|
+
* threshold (rows with `ingestedAt < now - returnedMs` are eligible for
|
|
93
|
+
* background purge).
|
|
94
|
+
*
|
|
95
|
+
* @param {{retentionDays?: number}} policy
|
|
96
|
+
* @returns {number|null} null means "no retention cap" (default)
|
|
97
|
+
*/
|
|
98
|
+
function resolveRetentionMs(policy) {
|
|
99
|
+
if (!policy || policy.retentionDays == null) return null;
|
|
100
|
+
const days = Number(policy.retentionDays);
|
|
101
|
+
if (!Number.isFinite(days) || days <= 0) return null;
|
|
102
|
+
return Math.floor(days * 24 * 60 * 60 * 1000);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Cross-check that the disclosure-fields strings on the adapter actually
|
|
107
|
+
* cover every source's declared fields. Used in tests to catch the
|
|
108
|
+
* "adapter shipped a field the UI never warned about" drift bug.
|
|
109
|
+
*
|
|
110
|
+
* Returns { ok: boolean, missing: string[] } — `missing` lists
|
|
111
|
+
* "source:field" pairs that the adapter doesn't declare.
|
|
112
|
+
*/
|
|
113
|
+
function checkDisclosureCoverage(adapterFields) {
|
|
114
|
+
const declared = new Set(adapterFields || []);
|
|
115
|
+
const missing = [];
|
|
116
|
+
for (const desc of Object.values(SOURCE_DESCRIPTORS)) {
|
|
117
|
+
const expected = `${desc.key}:${desc.fields.join(",")}`;
|
|
118
|
+
const sourceHit = Array.from(declared).find((s) => s.startsWith(`${desc.key}:`));
|
|
119
|
+
if (!sourceHit) {
|
|
120
|
+
missing.push(expected);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Verify every expected field appears in the declared string.
|
|
124
|
+
const declaredFields = new Set(
|
|
125
|
+
sourceHit.split(":", 2)[1].split(",").map((s) => s.trim()),
|
|
126
|
+
);
|
|
127
|
+
for (const f of desc.fields) {
|
|
128
|
+
if (!declaredFields.has(f)) missing.push(`${desc.key}:${f}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ok: missing.length === 0, missing };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Localized disclosure dialog payload — what the UI binds to.
|
|
136
|
+
* Returns a stable structure independent of the adapter instance, so the
|
|
137
|
+
* Vue store can be tested without the adapter wired in.
|
|
138
|
+
*/
|
|
139
|
+
function buildDisclosurePayload() {
|
|
140
|
+
return {
|
|
141
|
+
adapter: "system-data",
|
|
142
|
+
sensitivity: "high",
|
|
143
|
+
legalGate: true,
|
|
144
|
+
notice:
|
|
145
|
+
"短信和通话记录可能包含他人电话号码或对话内容;所有数据在本机加密存储,不向任何服务器上传(含 AI 分析)。",
|
|
146
|
+
sources: SOURCE_KEYS.map((key) => SOURCE_DESCRIPTORS[key]),
|
|
147
|
+
legalDeclaration:
|
|
148
|
+
[
|
|
149
|
+
"您声明:",
|
|
150
|
+
"1. 您是这部手机的合法使用者,对其上数据拥有访问权",
|
|
151
|
+
"2. 您理解短信内容可能涉及他人隐私,承诺仅在本机使用,不向任何第三方分发",
|
|
152
|
+
"3. 本工具不会将系统数据上传至云端(含 LLM 分析全部本地完成)",
|
|
153
|
+
"",
|
|
154
|
+
"不符合上述条件,请勿启用本 adapter。",
|
|
155
|
+
].join("\n"),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
SOURCE_DESCRIPTORS,
|
|
161
|
+
SOURCE_KEYS,
|
|
162
|
+
sanitizeInclude,
|
|
163
|
+
resolveRetentionMs,
|
|
164
|
+
checkDisclosureCoverage,
|
|
165
|
+
buildDisclosurePayload,
|
|
166
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SystemDataAdapter,
|
|
5
|
+
SYSTEM_DATA_ADAPTER_NAME,
|
|
6
|
+
SYSTEM_DATA_ADAPTER_VERSION,
|
|
7
|
+
DEFAULT_INCLUDE,
|
|
8
|
+
DEFAULT_REMOTE_PATHS,
|
|
9
|
+
SDCARD_WORKAROUND_PATHS,
|
|
10
|
+
} = require("./system-data-adapter");
|
|
11
|
+
const {
|
|
12
|
+
SOURCE_DESCRIPTORS,
|
|
13
|
+
SOURCE_KEYS,
|
|
14
|
+
sanitizeInclude,
|
|
15
|
+
resolveRetentionMs,
|
|
16
|
+
checkDisclosureCoverage,
|
|
17
|
+
buildDisclosurePayload,
|
|
18
|
+
} = require("./disclosure");
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
SystemDataAdapter,
|
|
22
|
+
SYSTEM_DATA_ADAPTER_NAME,
|
|
23
|
+
SYSTEM_DATA_ADAPTER_VERSION,
|
|
24
|
+
DEFAULT_INCLUDE,
|
|
25
|
+
DEFAULT_REMOTE_PATHS,
|
|
26
|
+
SDCARD_WORKAROUND_PATHS,
|
|
27
|
+
// Disclosure helpers (Phase 4.5.6)
|
|
28
|
+
SOURCE_DESCRIPTORS,
|
|
29
|
+
SOURCE_KEYS,
|
|
30
|
+
sanitizeInclude,
|
|
31
|
+
resolveRetentionMs,
|
|
32
|
+
checkDisclosureCoverage,
|
|
33
|
+
buildDisclosurePayload,
|
|
34
|
+
};
|