@chainlesschain/personal-data-hub 0.4.36 → 0.4.38

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.
@@ -104,6 +104,7 @@ const DISPLAY_NAMES = Object.freeze({
104
104
  "gov-12123": "交管12123",
105
105
  "browser-history-chrome": "Chrome 浏览历史",
106
106
  "browser-history-edge": "Edge 浏览历史",
107
+ "browser-history-aosp": "MIUI/AOSP 浏览历史",
107
108
  "vscode": "VS Code",
108
109
  "win-recent": "Windows 最近使用",
109
110
  "git-activity": "Git 提交记录",
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+
3
+ // BrowserHistoryAospAdapter — Android AOSP / MIUI stock Browser
4
+ // (`com.android.browser` → `browser2.db`). The MIUI stock browser is the
5
+ // default on Xiaomi/Redmi devices, so its history is a primary browsing-
6
+ // interest source the Chrome/Edge adapters can't read (different schema).
7
+ //
8
+ // Reuses BrowserHistoryChromeAdapter.normalize() (BROWSE Event / LINK Item
9
+ // shape → identical downstream analysis), but reads the AOSP `history` /
10
+ // `bookmarks` tables directly: a single plaintext SQLite with epoch-MS
11
+ // timestamps, NOT Chrome's urls/visits join with WebKit microseconds.
12
+ //
13
+ // Input is a path to a browser2.db pulled from the device (the contacts/
14
+ // browser providers block `content query`, so collection is root-read +
15
+ // pull, mirroring system-data-android). `opts.dbPath` (preferred) or
16
+ // `opts.profilePath`; a directory is accepted and `browser2.db` looked up
17
+ // inside it.
18
+ //
19
+ // Device-verified schema 2026-06-17, docs/internal/pdh-app-db-schemas.md.
20
+
21
+ const path = require("node:path");
22
+
23
+ const {
24
+ BrowserHistoryChromeAdapter,
25
+ } = require("../browser-history-chrome/adapter");
26
+ const reader = require("./aosp-db-reader");
27
+
28
+ const NAME = "browser-history-aosp";
29
+ const VERSION = "0.1.0";
30
+ const DB_FILENAME = "browser2.db";
31
+
32
+ class BrowserHistoryAospAdapter extends BrowserHistoryChromeAdapter {
33
+ constructor(opts = {}) {
34
+ super(opts);
35
+ // Parent set Chrome-shaped capabilities (json bookmarks) + profile fields;
36
+ // correct them for the AOSP SQLite-bookmarks / db-file layout.
37
+ this.capabilities = [
38
+ "sync:aosp-browser-history-sqlite",
39
+ "sync:aosp-browser-bookmarks-sqlite",
40
+ ];
41
+ this.dataDisclosure = {
42
+ ...this.dataDisclosure,
43
+ fields: ["history:url,title,visitTimeMs,visitCount", "bookmarks:url,name"],
44
+ };
45
+ this._dbPathOverride =
46
+ (typeof opts.dbPath === "string" && opts.dbPath) ||
47
+ (typeof opts.profilePath === "string" && opts.profilePath) ||
48
+ null;
49
+ this._deps.reader = reader;
50
+ }
51
+
52
+ // Virtual — called by the parent constructor; drives name/version/browser.
53
+ _browserConfig() {
54
+ return {
55
+ name: NAME,
56
+ version: VERSION,
57
+ browser: "aosp",
58
+ defaultProfileDir: () => null, // host has no AOSP browser; db is pulled
59
+ };
60
+ }
61
+
62
+ _resolveDbPath(opts = {}) {
63
+ const raw =
64
+ (typeof opts.dbPath === "string" && opts.dbPath) ||
65
+ (typeof opts.profilePath === "string" && opts.profilePath) ||
66
+ this._dbPathOverride;
67
+ if (!raw) return null;
68
+ // Accept either the file itself or a directory containing browser2.db.
69
+ try {
70
+ if (
71
+ this._deps.fs.existsSync(raw) &&
72
+ this._deps.fs.statSync(raw).isDirectory()
73
+ ) {
74
+ return path.join(raw, DB_FILENAME);
75
+ }
76
+ } catch (_e) {
77
+ // stat failed — fall through and treat `raw` as a file path
78
+ }
79
+ return raw;
80
+ }
81
+
82
+ async authenticate(ctx = {}) {
83
+ const dbPath = this._resolveDbPath(ctx);
84
+ if (!dbPath) {
85
+ return {
86
+ ok: false,
87
+ reason: "DB_PATH_UNRESOLVED",
88
+ message: `pass opts.dbPath pointing at a ${DB_FILENAME} pulled from the device`,
89
+ };
90
+ }
91
+ if (!this._deps.fs.existsSync(dbPath)) {
92
+ return {
93
+ ok: false,
94
+ reason: "DB_NOT_FOUND",
95
+ message: `no ${DB_FILENAME} at ${dbPath}`,
96
+ };
97
+ }
98
+ return { ok: true, mode: "file-import", dbPath };
99
+ }
100
+
101
+ async healthCheck() {
102
+ const dbPath = this._resolveDbPath({});
103
+ const ok = !!dbPath && this._deps.fs.existsSync(dbPath);
104
+ return { ok, lastChecked: Date.now() };
105
+ }
106
+
107
+ async *sync(opts = {}) {
108
+ const dbPath = this._resolveDbPath(opts);
109
+ if (!dbPath || !this._deps.fs.existsSync(dbPath)) {
110
+ throw new Error(
111
+ `${this.name}.sync: no ${DB_FILENAME} at ${dbPath || "?"} — set opts.dbPath`,
112
+ );
113
+ }
114
+
115
+ const includeHistory = opts.include?.history !== false;
116
+ const includeBookmarks = opts.include?.bookmarks !== false;
117
+ const limit =
118
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
119
+ const capturedAt = Date.now();
120
+ let emitted = 0;
121
+
122
+ if (includeHistory) {
123
+ let tmp = null;
124
+ try {
125
+ tmp = this._deps.reader.copyDbSnapshot(dbPath, { fs: this._deps.fs });
126
+ for (const v of this._deps.reader.readHistory(tmp, {
127
+ since: opts.since,
128
+ limit: Number.isFinite(limit) ? limit : undefined,
129
+ })) {
130
+ if (emitted >= limit) return;
131
+ yield {
132
+ kind: "visit",
133
+ originalId: `aosp-visit:${dbPath}:${v.visitId}`,
134
+ capturedAt,
135
+ payload: { ...v, profileDir: dbPath },
136
+ };
137
+ emitted += 1;
138
+ }
139
+ } finally {
140
+ if (tmp) this._deps.reader.cleanupDbSnapshot(tmp, { fs: this._deps.fs });
141
+ }
142
+ }
143
+
144
+ if (includeBookmarks) {
145
+ let tmp = null;
146
+ try {
147
+ tmp = this._deps.reader.copyDbSnapshot(dbPath, { fs: this._deps.fs });
148
+ for (const b of this._deps.reader.readBookmarks(tmp, {
149
+ fs: this._deps.fs,
150
+ })) {
151
+ if (emitted >= limit) return;
152
+ yield {
153
+ kind: "bookmark",
154
+ originalId: `aosp-bookmark:${dbPath}:${b.id || b.url}`,
155
+ capturedAt,
156
+ payload: { ...b, profileDir: dbPath },
157
+ };
158
+ emitted += 1;
159
+ }
160
+ } finally {
161
+ if (tmp) this._deps.reader.cleanupDbSnapshot(tmp, { fs: this._deps.fs });
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ module.exports = {
168
+ BrowserHistoryAospAdapter,
169
+ BROWSER_HISTORY_AOSP_NAME: NAME,
170
+ BROWSER_HISTORY_AOSP_VERSION: VERSION,
171
+ };
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+
3
+ // AOSP / MIUI stock Browser reader (`com.android.browser` → `browser2.db`).
4
+ //
5
+ // Distinct schema from Chrome: a single PLAINTEXT `history` table with
6
+ // epoch-MILLISECOND timestamps (no urls/visits join, no WebKit microseconds)
7
+ // plus a `bookmarks` table. Device-verified 2026-06-17, see
8
+ // docs/internal/pdh-app-db-schemas.md → "AOSP 浏览器 MIUI Browser".
9
+ //
10
+ // Columns vary slightly by ROM build, so every column is resolved via
11
+ // `PRAGMA table_info` rather than hard-coded — the same defensive approach
12
+ // the douyin/weibo on-device parsers use.
13
+
14
+ const path = require("node:path");
15
+ const os = require("node:os");
16
+ const fs = require("node:fs");
17
+
18
+ // Mirror chrome-db-reader.loadDatabase: prefer the multi-cipher build, fall
19
+ // back to vanilla, and smoke-test instantiation (require() returns the JS
20
+ // class even when the native binding is ABI-mismatched; `new` is what throws).
21
+ let _cachedDatabaseClass = null;
22
+ function loadDatabase() {
23
+ if (_cachedDatabaseClass) return _cachedDatabaseClass;
24
+ for (const mod of ["better-sqlite3-multiple-ciphers", "better-sqlite3"]) {
25
+ let cls;
26
+ try {
27
+ // eslint-disable-next-line global-require
28
+ cls = require(mod);
29
+ } catch (_e) {
30
+ continue; // require failed, try next candidate
31
+ }
32
+ try {
33
+ const probe = new cls(":memory:");
34
+ probe.close();
35
+ _cachedDatabaseClass = cls;
36
+ return cls;
37
+ } catch (_e) {
38
+ // ABI mismatch — try next candidate
39
+ }
40
+ }
41
+ throw new Error(
42
+ "aosp-db-reader: neither better-sqlite3-multiple-ciphers nor better-sqlite3 loaded — both ABI-mismatched",
43
+ );
44
+ }
45
+
46
+ // Scale a raw timestamp to epoch-ms by magnitude (seconds/ms/µs/ns). browser2.db
47
+ // stores `date` in ms, but stay defensive against ROM variants.
48
+ function normalizeEpochMs(t) {
49
+ if (t == null) return null;
50
+ const n = typeof t === "bigint" ? Number(t) : Number(t);
51
+ if (!Number.isFinite(n) || n <= 0) return null;
52
+ if (n < 1e11) return Math.round(n * 1000); // seconds
53
+ if (n < 1e14) return Math.round(n); // milliseconds
54
+ if (n < 1e17) return Math.round(n / 1000); // microseconds
55
+ return Math.round(n / 1e6); // nanoseconds
56
+ }
57
+
58
+ // browser2.db is locked while the browser runs (and we usually read a
59
+ // root-pulled copy anyway). Snapshot via copyFileSync, carrying WAL sidecars.
60
+ function copyDbSnapshot(dbPath, opts = {}) {
61
+ const fsMod = opts.fs || fs;
62
+ if (!fsMod.existsSync(dbPath)) {
63
+ const err = new Error(`AOSP browser2.db not found at ${dbPath}`);
64
+ err.code = "AOSP_BROWSER_DB_NOT_FOUND";
65
+ throw err;
66
+ }
67
+ const tmp = path.join(
68
+ os.tmpdir(),
69
+ `pdh-aosp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.db`,
70
+ );
71
+ fsMod.copyFileSync(dbPath, tmp);
72
+ for (const ext of ["-journal", "-wal", "-shm"]) {
73
+ const w = dbPath + ext;
74
+ if (fsMod.existsSync(w)) {
75
+ try {
76
+ fsMod.copyFileSync(w, tmp + ext);
77
+ } catch (_e) {
78
+ // Sidecar copy failure is non-fatal — we want the pre-WAL state.
79
+ }
80
+ }
81
+ }
82
+ return tmp;
83
+ }
84
+
85
+ function cleanupDbSnapshot(tmpPath, opts = {}) {
86
+ const fsMod = opts.fs || fs;
87
+ for (const ext of ["", "-journal", "-wal", "-shm"]) {
88
+ try {
89
+ fsMod.unlinkSync(tmpPath + ext);
90
+ } catch (_e) {
91
+ // best-effort
92
+ }
93
+ }
94
+ }
95
+
96
+ function pickCol(cols, candidates) {
97
+ const set = new Set(cols.map((c) => c.name));
98
+ for (const c of candidates) if (set.has(c)) return c;
99
+ return null;
100
+ }
101
+
102
+ // Yields history rows ascending by visit time so the registry watermark
103
+ // (max occurredAt) advances monotonically across syncs. Shape matches what
104
+ // BrowserHistoryChromeAdapter.normalize() consumes for kind="visit".
105
+ function* readHistory(tmpPath, opts = {}) {
106
+ const since = Number.isInteger(opts.since) && opts.since > 0 ? opts.since : 0;
107
+ const limit =
108
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 200_000;
109
+ const Database = loadDatabase();
110
+ const db = new Database(tmpPath, { readonly: true });
111
+ try {
112
+ const cols = db.prepare("PRAGMA table_info(history)").all();
113
+ if (cols.length === 0) return; // no history table in this DB
114
+ const idCol = pickCol(cols, ["_id", "id"]);
115
+ const urlCol = pickCol(cols, ["url"]);
116
+ const titleCol = pickCol(cols, ["title"]);
117
+ const dateCol = pickCol(cols, ["date", "created", "visit_time"]);
118
+ const visitsCol = pickCol(cols, ["visits", "visit_count"]);
119
+ if (!urlCol || !dateCol) return; // schema we don't recognise
120
+ const fields = [
121
+ `${idCol || "rowid"} AS hid`,
122
+ `${urlCol} AS url`,
123
+ `${titleCol || "NULL"} AS title`,
124
+ `${dateCol} AS date`,
125
+ `${visitsCol || "0"} AS visits`,
126
+ ];
127
+ const stmt = db.prepare(
128
+ `SELECT ${fields.join(", ")} FROM history
129
+ WHERE ${dateCol} > ?
130
+ ORDER BY ${dateCol} ASC
131
+ LIMIT ?`,
132
+ );
133
+ for (const r of stmt.iterate(since, limit)) {
134
+ yield {
135
+ visitId: r.hid,
136
+ url: typeof r.url === "string" ? r.url : "",
137
+ title: typeof r.title === "string" ? r.title : "",
138
+ visitTimeMs: normalizeEpochMs(r.date),
139
+ visitCount: Number.isInteger(r.visits) ? r.visits : 0,
140
+ };
141
+ }
142
+ } finally {
143
+ db.close();
144
+ }
145
+ }
146
+
147
+ // Yields bookmark leaves (folder=0, deleted=0). Shape matches what
148
+ // BrowserHistoryChromeAdapter.normalize() consumes for kind="bookmark".
149
+ function* readBookmarks(tmpPath, opts = {}) {
150
+ const Database = loadDatabase();
151
+ const db = new Database(tmpPath, { readonly: true });
152
+ try {
153
+ const cols = db.prepare("PRAGMA table_info(bookmarks)").all();
154
+ if (cols.length === 0) return; // no bookmarks table
155
+ const idCol = pickCol(cols, ["_id", "id"]);
156
+ const urlCol = pickCol(cols, ["url"]);
157
+ const titleCol = pickCol(cols, ["title"]);
158
+ const folderCol = pickCol(cols, ["folder"]);
159
+ const deletedCol = pickCol(cols, ["deleted"]);
160
+ const createdCol = pickCol(cols, ["created", "date"]);
161
+ if (!urlCol) return;
162
+ const where = [];
163
+ if (folderCol) where.push(`${folderCol} = 0`); // folder=0 → actual bookmark leaf
164
+ if (deletedCol) where.push(`${deletedCol} = 0`);
165
+ where.push(`${urlCol} IS NOT NULL AND ${urlCol} != ''`);
166
+ const fields = [
167
+ `${idCol || "rowid"} AS bid`,
168
+ `${urlCol} AS url`,
169
+ `${titleCol || "NULL"} AS title`,
170
+ `${createdCol || "NULL"} AS created`,
171
+ ];
172
+ const stmt = db.prepare(
173
+ `SELECT ${fields.join(", ")} FROM bookmarks WHERE ${where.join(" AND ")}`,
174
+ );
175
+ for (const r of stmt.all()) {
176
+ const url = typeof r.url === "string" ? r.url : "";
177
+ yield {
178
+ id: r.bid,
179
+ name: typeof r.title === "string" && r.title.length > 0 ? r.title : url,
180
+ url,
181
+ dateAddedMs: r.created != null ? normalizeEpochMs(r.created) : null,
182
+ folderPath: null, // browser2.db bookmarks are flat (no folder tree)
183
+ };
184
+ }
185
+ } finally {
186
+ db.close();
187
+ }
188
+ }
189
+
190
+ module.exports = {
191
+ loadDatabase,
192
+ normalizeEpochMs,
193
+ copyDbSnapshot,
194
+ cleanupDbSnapshot,
195
+ readHistory,
196
+ readBookmarks,
197
+ };
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+
3
+ const {
4
+ BrowserHistoryAospAdapter,
5
+ BROWSER_HISTORY_AOSP_NAME,
6
+ BROWSER_HISTORY_AOSP_VERSION,
7
+ } = require("./adapter");
8
+ const reader = require("./aosp-db-reader");
9
+
10
+ module.exports = {
11
+ BrowserHistoryAospAdapter,
12
+ BROWSER_HISTORY_AOSP_NAME,
13
+ BROWSER_HISTORY_AOSP_VERSION,
14
+ copyDbSnapshot: reader.copyDbSnapshot,
15
+ cleanupDbSnapshot: reader.cleanupDbSnapshot,
16
+ readHistory: reader.readHistory,
17
+ readBookmarks: reader.readBookmarks,
18
+ normalizeEpochMs: reader.normalizeEpochMs,
19
+ };
@@ -127,9 +127,12 @@ async function extractBill(email, opts = {}) {
127
127
  if (billingPeriod && billingPeriod.start instanceof Date) {
128
128
  billingMonth = formatMonthKey(billingPeriod.start);
129
129
  } else if (dueDate instanceof Date) {
130
- // "11 月对账单 due 12-25" → bill is for month BEFORE due
131
- const prev = new Date(dueDate);
132
- prev.setMonth(prev.getMonth() - 1);
130
+ // "11 月对账单 due 12-25" → bill is for month BEFORE due.
131
+ // Build from (year, month-1, 1): a naive setMonth(getMonth()-1) keeps the
132
+ // day, so a due-day of 29-31 overflows into the wrong month (Mar 31 − 1mo
133
+ // → Feb 31 → rolls to Mar 3 → wrong "-03" instead of "-02"). Month -1
134
+ // (January → December prior year) is handled correctly by the Date ctor.
135
+ const prev = new Date(dueDate.getFullYear(), dueDate.getMonth() - 1, 1);
133
136
  billingMonth = formatMonthKey(prev);
134
137
  } else {
135
138
  const m = (email.subject || "").match(/(\d{1,2})\s*月.*(?:对账单|月结|账单)/);
@@ -82,10 +82,15 @@ class TimelineSkill extends AnalysisSkill {
82
82
  }
83
83
 
84
84
  async run(options = {}) {
85
- const window = this.resolveTimeWindow({
86
- sinceDays: options.sinceDays ?? (options.since ? null : 7), // default 7d
87
- ...options,
88
- });
85
+ // Default to the last 7 days only when the caller gave no window at all.
86
+ // The old `{ sinceDays: …, ...options }` merge injected sinceDays:7 ahead
87
+ // of resolveTimeWindow's since > sinceDays > sinceMonths precedence, so an
88
+ // explicit `sinceMonths: N` was silently shadowed into a 7-day window.
89
+ const hasWindow =
90
+ (typeof options.since === "number" && options.since > 0) ||
91
+ (typeof options.sinceDays === "number" && options.sinceDays > 0) ||
92
+ (typeof options.sinceMonths === "number" && options.sinceMonths > 0);
93
+ const window = this.resolveTimeWindow(hasWindow ? options : { sinceDays: 7 });
89
94
  const limit = Number.isFinite(options.limit) && options.limit > 0
90
95
  ? Math.min(options.limit, 1000)
91
96
  : 100;
package/lib/index.js CHANGED
@@ -113,6 +113,7 @@ const mobileExtractor = require("./mobile-extractor");
113
113
  const systemDataAndroid = require("./adapters/system-data-android");
114
114
  const browserHistoryChrome = require("./adapters/browser-history-chrome");
115
115
  const browserHistoryEdge = require("./adapters/browser-history-edge");
116
+ const browserHistoryAosp = require("./adapters/browser-history-aosp");
116
117
  const vscodeAdapter = require("./adapters/vscode");
117
118
  const winRecentAdapter = require("./adapters/win-recent");
118
119
  const gitActivityAdapter = require("./adapters/git-activity");
@@ -398,6 +399,12 @@ module.exports = {
398
399
  BROWSER_HISTORY_EDGE_NAME: browserHistoryEdge.BROWSER_HISTORY_EDGE_NAME,
399
400
  BROWSER_HISTORY_EDGE_VERSION: browserHistoryEdge.BROWSER_HISTORY_EDGE_VERSION,
400
401
 
402
+ // AOSP / MIUI stock browser — different schema (browser2.db, ms timestamps),
403
+ // reuses the Chrome normalize() for an identical BROWSE Event / LINK Item shape.
404
+ BrowserHistoryAospAdapter: browserHistoryAosp.BrowserHistoryAospAdapter,
405
+ BROWSER_HISTORY_AOSP_NAME: browserHistoryAosp.BROWSER_HISTORY_AOSP_NAME,
406
+ BROWSER_HISTORY_AOSP_VERSION: browserHistoryAosp.BROWSER_HISTORY_AOSP_VERSION,
407
+
401
408
  // VS Code — workspace history + global terminal command/dir history.
402
409
  VSCodeAdapter: vscodeAdapter.VSCodeAdapter,
403
410
  VSCODE_NAME: vscodeAdapter.VSCODE_NAME,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlesschain/personal-data-hub",
3
- "version": "0.4.36",
3
+ "version": "0.4.38",
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",