@chainlesschain/personal-data-hub 0.4.35 → 0.4.37
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/lib/adapter-guide.js +1 -0
- package/lib/adapters/browser-history-aosp/adapter.js +171 -0
- package/lib/adapters/browser-history-aosp/aosp-db-reader.js +197 -0
- package/lib/adapters/browser-history-aosp/index.js +19 -0
- package/lib/forensics/qzone-collect.js +168 -0
- package/lib/forensics/wechat-collect.js +99 -1
- package/lib/index.js +7 -0
- package/package.json +2 -1
package/lib/adapter-guide.js
CHANGED
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* qzone-collect — QQ空间 (Qzone) collector core: 说说 / 留言板 / 相册 → vault events.
|
|
4
|
+
*
|
|
5
|
+
* Qzone has NO local browsable DB (the QQNT databases only cache per-contact
|
|
6
|
+
* "latest feed" preview snippets), so this is the API path: Qzone CGI endpoints
|
|
7
|
+
* authed with the account's qzone-domain `p_skey` + `uin` + a `g_tk` token
|
|
8
|
+
* derived from p_skey (the bkn hash). Pure Node — the only side effect is the
|
|
9
|
+
* caller-supplied `fetchImpl` (defaults to global fetch), so the parsers are
|
|
10
|
+
* unit-testable and the same core runs on PC (`cc hub collect-qzone --cookie`)
|
|
11
|
+
* and in-APK (the Android app captures the cookie via a WebView and feeds it in).
|
|
12
|
+
*
|
|
13
|
+
* Cookie note: the base `.qq.com` skey is rejected by Qzone ("请先登录空间") —
|
|
14
|
+
* the qzone-domain `p_skey` is required (a browser login to user.qzone.qq.com,
|
|
15
|
+
* or the in-app WebView, yields it). Extracted from
|
|
16
|
+
* scripts/android/pdh-qzone-collect.mjs (behaviour identical).
|
|
17
|
+
*/
|
|
18
|
+
const SELF_ID = 'person-qq-self';
|
|
19
|
+
const UA = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Mobile Safari/537.36';
|
|
20
|
+
const SRC = (originalId, at) => ({ adapter: 'qzone', adapterVersion: '0.1.0', originalId, capturedAt: at || Date.now(), capturedBy: 'api' });
|
|
21
|
+
|
|
22
|
+
/** Qzone bkn/g_tk hash over p_skey (or skey). */
|
|
23
|
+
function gtk(s) { let h = 5381; for (let i = 0; i < String(s).length; i++) h += (h << 5) + String(s).charCodeAt(i); return h & 0x7fffffff; }
|
|
24
|
+
|
|
25
|
+
function parseCookieStr(s) { const o = {}; for (const part of String(s).split(/;\s*/)) { const i = part.indexOf('='); if (i > 0) o[part.slice(0, i).trim()] = part.slice(i + 1).trim(); } return o; }
|
|
26
|
+
function cookieHeader(ck) { return Object.entries(ck).map(([k, v]) => `${k}=${v}`).join('; '); }
|
|
27
|
+
function stripHtml(s) {
|
|
28
|
+
return String(s || '')
|
|
29
|
+
.replace(/<img[^>]*>/gi, '')
|
|
30
|
+
.replace(/<[^>]+>/g, '')
|
|
31
|
+
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
32
|
+
.replace(/\s+/g, ' ').trim();
|
|
33
|
+
}
|
|
34
|
+
function beijingMs(s) { const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(String(s || '')); if (!m) return 0; return Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}+08:00`) || 0; }
|
|
35
|
+
function unwrap(text) { return String(text).trim().replace(/^[\w$]+\(/, '').replace(/\);?\s*$/, ''); }
|
|
36
|
+
|
|
37
|
+
// ── 说说 (emotion_cgi_msglist_v6) → EVENT(post) ─────────────────────────────
|
|
38
|
+
function parseQzoneFeed(text) {
|
|
39
|
+
let json; try { json = JSON.parse(unwrap(text)); } catch { return { code: -1, events: [] }; }
|
|
40
|
+
if (json.code !== undefined && json.code !== 0) return { code: json.code, message: json.message, events: [] };
|
|
41
|
+
const list = json.msglist || (json.result && json.result.msglist) || [];
|
|
42
|
+
const events = [];
|
|
43
|
+
for (const it of list) {
|
|
44
|
+
const tid = it.tid || it.t1_tid || it.cellid;
|
|
45
|
+
const occurredAt = (Number(it.created_time) || 0) * 1000;
|
|
46
|
+
if (!tid || !occurredAt) continue;
|
|
47
|
+
const txt = (it.content || it.summary || '').replace(/\s+/g, ' ').trim();
|
|
48
|
+
const pics = Array.isArray(it.pic) ? it.pic.length : 0;
|
|
49
|
+
if (!txt && !pics) continue;
|
|
50
|
+
events.push({
|
|
51
|
+
type: 'event', subtype: 'post', id: `qzone:${tid}`,
|
|
52
|
+
occurredAt, actor: SELF_ID, participants: [SELF_ID],
|
|
53
|
+
content: { title: (txt || '[图片] 我的说说').slice(0, 80), text: txt || undefined },
|
|
54
|
+
source: SRC(`qzone-${tid}`, occurredAt),
|
|
55
|
+
extra: { kind: 'qzone-shuoshuo', tid, mediaCount: pics, cmtnum: it.cmtnum || 0, secret: !!it.secret },
|
|
56
|
+
ingestedAt: Date.now(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return { code: 0, events, total: json.total != null ? json.total : (json.result && json.result.total) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── 留言板 (get_msgb) → EVENT(message) by the commenter ────────────────────
|
|
63
|
+
function parseGuestbook(text) {
|
|
64
|
+
let json; try { json = JSON.parse(unwrap(text)); } catch { return { code: -1, events: [], persons: [] }; }
|
|
65
|
+
if (json.code !== 0) return { code: json.code, message: json.message, events: [], persons: [] };
|
|
66
|
+
const list = (json.data && json.data.commentList) || [];
|
|
67
|
+
const events = [], persons = new Map();
|
|
68
|
+
for (const c of list) {
|
|
69
|
+
const id = c.id; const occurredAt = beijingMs(c.pubtime);
|
|
70
|
+
const txt = stripHtml(c.htmlContent || c.content || '');
|
|
71
|
+
if (!id || !occurredAt || !txt) continue;
|
|
72
|
+
const fromUin = String(c.uin || '');
|
|
73
|
+
const fromNick = c.nickname || fromUin;
|
|
74
|
+
const actor = fromUin ? `person-qq-${fromUin}` : SELF_ID;
|
|
75
|
+
if (fromUin && !persons.has(actor)) persons.set(actor, { type: 'person', subtype: 'contact', id: actor, names: fromNick !== fromUin ? [fromNick, fromUin] : [fromUin], identifiers: { qqUin: fromUin }, source: SRC(actor), ingestedAt: Date.now() });
|
|
76
|
+
events.push({
|
|
77
|
+
type: 'event', subtype: 'message', id: `qzone-msgb:${id}`,
|
|
78
|
+
occurredAt, actor, participants: [actor, SELF_ID],
|
|
79
|
+
content: { title: txt.slice(0, 80), text: txt },
|
|
80
|
+
source: SRC(`qzone-msgb-${id}`, occurredAt),
|
|
81
|
+
extra: { kind: 'qzone-guestbook', fromUin, fromNick },
|
|
82
|
+
ingestedAt: Date.now(),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { code: 0, events, persons: [...persons.values()], total: json.data && json.data.total };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── 相册 (fcg_list_album_v3) → EVENT(media) per album ──────────────────────
|
|
89
|
+
function parseAlbums(text) {
|
|
90
|
+
let json; try { json = JSON.parse(unwrap(text)); } catch { return { code: -1, events: [] }; }
|
|
91
|
+
if (json.code !== 0) return { code: json.code, message: json.message, events: [] };
|
|
92
|
+
const list = (json.data && json.data.albumList) || [];
|
|
93
|
+
const events = [];
|
|
94
|
+
for (const a of list) {
|
|
95
|
+
if (!a.id) continue;
|
|
96
|
+
const occurredAt = (Number(a.createtime) || 0) * 1000;
|
|
97
|
+
const name = a.name || '(相册)';
|
|
98
|
+
events.push({
|
|
99
|
+
type: 'event', subtype: 'media', id: `qzone-album:${a.id}`,
|
|
100
|
+
occurredAt: occurredAt || Date.now(), actor: SELF_ID, participants: [SELF_ID],
|
|
101
|
+
content: { title: `相册:${name}(${a.total || 0} 张)`, text: a.desc || undefined },
|
|
102
|
+
source: SRC(`qzone-album-${a.id}`, occurredAt),
|
|
103
|
+
extra: { kind: 'qzone-album', albumId: a.id, photoCount: a.total || 0, desc: a.desc || '', commentCount: a.comment || 0 },
|
|
104
|
+
ingestedAt: Date.now(),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { code: 0, events, total: (json.data && json.data.albumsInUser) != null ? json.data.albumsInUser : list.length };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function qproxy(domainPath, params) {
|
|
111
|
+
const qs = Object.entries({ format: 'json', inCharset: 'utf-8', outCharset: 'utf-8', source: 'qzone', plat: 'qzone', ...params }).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
|
112
|
+
return `https://user.qzone.qq.com/proxy/domain/${domainPath}?${qs}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Collect Qzone data into a vault batch. `fetchImpl(url, opts)` is injectable
|
|
117
|
+
* (defaults to global fetch) so this is testable offline and runs in-APK.
|
|
118
|
+
* @returns {Promise<{ok, uin, events, persons, counts, reason?}>}
|
|
119
|
+
*/
|
|
120
|
+
async function collectQzone({ uin, cookie, what = ['shuoshuo'], max = 500, fetchImpl } = {}) {
|
|
121
|
+
const ck = typeof cookie === 'string' ? parseCookieStr(cookie) : (cookie || {});
|
|
122
|
+
// QQ uin cookies are `o0<uin>` — strip the o/0 prefix (uins never have leading zeros).
|
|
123
|
+
const cleanUin = (s) => String(s || '').replace(/\D/g, '').replace(/^0+/, '');
|
|
124
|
+
uin = cleanUin(uin) || cleanUin(ck.uin) || cleanUin(ck.p_uin);
|
|
125
|
+
const pskey = ck.p_skey || ck.skey;
|
|
126
|
+
if (!uin || !pskey) return { ok: false, reason: 'missing uin or p_skey', events: [], persons: [], counts: {} };
|
|
127
|
+
const _fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
|
128
|
+
if (!_fetch) throw new Error('qzone collect: no fetch implementation available');
|
|
129
|
+
const wantSet = new Set(Array.isArray(what) ? what : String(what).split(',').map((s) => s.trim()));
|
|
130
|
+
const g = gtk(pskey);
|
|
131
|
+
const headers = { Cookie: cookieHeader(ck), Referer: `https://user.qzone.qq.com/${uin}`, 'User-Agent': UA };
|
|
132
|
+
const get = async (url) => { const r = await _fetch(url, { headers }); return typeof r.text === 'function' ? r.text() : r; };
|
|
133
|
+
|
|
134
|
+
const events = [], persons = new Map();
|
|
135
|
+
const counts = {};
|
|
136
|
+
|
|
137
|
+
if (wantSet.has('shuoshuo')) {
|
|
138
|
+
let n = 0;
|
|
139
|
+
for (let pos = 0; pos < max; pos += 20) {
|
|
140
|
+
const r = parseQzoneFeed(await get(qproxy('taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6', { uin, hostUin: uin, num: 20, pos, g_tk: g, need_private_comment: 1 })));
|
|
141
|
+
if (r.code !== 0 || !r.events.length) break;
|
|
142
|
+
events.push(...r.events); n += r.events.length;
|
|
143
|
+
if (r.total != null && n >= r.total) break;
|
|
144
|
+
}
|
|
145
|
+
counts.shuoshuo = n;
|
|
146
|
+
}
|
|
147
|
+
if (wantSet.has('msgb')) {
|
|
148
|
+
let n = 0, total = null;
|
|
149
|
+
for (let start = 0; start < max; start += 20) {
|
|
150
|
+
const r = parseGuestbook(await get(qproxy('m.qzone.qq.com/cgi-bin/new/get_msgb', { uin, hostUin: uin, num: 20, start, g_tk: g })));
|
|
151
|
+
if (r.code !== 0) break;
|
|
152
|
+
total = r.total;
|
|
153
|
+
if (!r.events.length) break;
|
|
154
|
+
events.push(...r.events); for (const p of r.persons) persons.set(p.id, p); n += r.events.length;
|
|
155
|
+
if (total != null && n >= total) break;
|
|
156
|
+
}
|
|
157
|
+
counts.msgb = n;
|
|
158
|
+
}
|
|
159
|
+
if (wantSet.has('album')) {
|
|
160
|
+
const r = parseAlbums(await get(qproxy('photo.qzone.qq.com/fcgi-bin/fcg_list_album_v3', { g_tk: g, hostUin: uin, uin, mode: 2, pageStart: 0, pageNum: 200 })));
|
|
161
|
+
if (r.code === 0) { events.push(...r.events); counts.album = r.events.length; }
|
|
162
|
+
else counts.album = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { ok: true, uin, events, persons: [...persons.values()], counts };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { gtk, parseCookieStr, stripHtml, parseQzoneFeed, parseGuestbook, parseAlbums, collectQzone, SELF_ID };
|
|
@@ -171,4 +171,102 @@ function parseEvents(Database, dbPath, _self) {
|
|
|
171
171
|
return { events, persons: [...persons.values()], topics: [...topics.values()] };
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
// ── 朋友圈 (SnsMicroMsg.db, PLAINTEXT — no SQLCipher) ───────────────────────
|
|
175
|
+
// Unlike EnMicroMsg.db, SnsMicroMsg.db is NOT encrypted (header = "SQLite
|
|
176
|
+
// format 3\0"), so it opens directly. SnsInfo.content is a protobuf
|
|
177
|
+
// TimelineObject: the post text is top-level field 5 (contentDesc), media are
|
|
178
|
+
// qpic.cn URLs embedded in the blob, and the poster nickname lives in attrBuf.
|
|
179
|
+
// SnsInfo.createTime is epoch SECONDS. Verified on chopin (WeChat 8.0.74):
|
|
180
|
+
// account 60e2c317… had 2931 posts (2623 with text) readable without any key.
|
|
181
|
+
function _pbReadVarint(buf, pos) {
|
|
182
|
+
let shift = 0, result = 0n;
|
|
183
|
+
while (pos < buf.length) {
|
|
184
|
+
const b = buf[pos++];
|
|
185
|
+
result |= BigInt(b & 0x7f) << BigInt(shift);
|
|
186
|
+
if (!(b & 0x80)) break;
|
|
187
|
+
shift += 7;
|
|
188
|
+
}
|
|
189
|
+
return [result, pos];
|
|
190
|
+
}
|
|
191
|
+
// Walk top-level protobuf fields → { fieldNum: [Buffer|BigInt, …] }. Best-effort
|
|
192
|
+
// (stops on malformed input); length-delimited values are returned as slices.
|
|
193
|
+
function _pbFields(buf) {
|
|
194
|
+
const out = {};
|
|
195
|
+
let pos = 0;
|
|
196
|
+
while (pos < buf.length) {
|
|
197
|
+
let tag; [tag, pos] = _pbReadVarint(buf, pos);
|
|
198
|
+
const field = Number(tag >> 3n), wire = Number(tag & 7n);
|
|
199
|
+
if (field === 0) break;
|
|
200
|
+
let val;
|
|
201
|
+
if (wire === 0) { [val, pos] = _pbReadVarint(buf, pos); }
|
|
202
|
+
else if (wire === 2) { let len; [len, pos] = _pbReadVarint(buf, pos); len = Number(len); if (len < 0 || pos + len > buf.length) break; val = buf.subarray(pos, pos + len); pos += len; }
|
|
203
|
+
else if (wire === 1) { val = buf.subarray(pos, pos + 8); pos += 8; }
|
|
204
|
+
else if (wire === 5) { val = buf.subarray(pos, pos + 4); pos += 4; }
|
|
205
|
+
else break;
|
|
206
|
+
(out[field] ||= []).push(val);
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
function snsPostText(contentBuf) {
|
|
211
|
+
try { const f = _pbFields(contentBuf); if (f[5] && f[5].length) { const t = f[5][0].toString('utf8').trim(); if (t) return t; } } catch { /* not a TimelineObject */ }
|
|
212
|
+
return '';
|
|
213
|
+
}
|
|
214
|
+
function snsMediaUrls(contentBuf) {
|
|
215
|
+
const s = contentBuf.toString('latin1'); const urls = new Set();
|
|
216
|
+
const re = /https?:\/\/[A-Za-z0-9._-]*qpic\.cn[A-Za-z0-9._\-/?=&%]+/g; let m;
|
|
217
|
+
while ((m = re.exec(s))) urls.add(m[0]);
|
|
218
|
+
return [...urls];
|
|
219
|
+
}
|
|
220
|
+
function snsNickname(attrBuf, wxid) {
|
|
221
|
+
try { const f = _pbFields(attrBuf); for (const vals of Object.values(f)) for (const v of vals) { if (Buffer.isBuffer(v)) { const s = v.toString('utf8'); if (s && s !== wxid && !/^wxid_/.test(s) && /[一-鿿A-Za-z]/.test(s) && s.length <= 40 && !/[\x00-\x08]/.test(s)) return s; } } } catch { /* ignore */ }
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Parse a PLAINTEXT SnsMicroMsg.db → 朋友圈 vault batch { events, persons, topics }.
|
|
227
|
+
* Each SnsInfo row → EVENT(post) attributed to the poster. `selfWxid` (optional)
|
|
228
|
+
* maps the user's own posts to SELF_ID; `nameMap` (wxid → displayName, e.g. from
|
|
229
|
+
* the matching account's decrypted rcontact) overrides attrBuf nicknames.
|
|
230
|
+
*/
|
|
231
|
+
function parseSnsEvents(Database, dbPath, { selfWxid, nameMap } = {}) {
|
|
232
|
+
const src = new Database(dbPath, { readonly: true });
|
|
233
|
+
const events = [];
|
|
234
|
+
const persons = new Map();
|
|
235
|
+
const names = nameMap instanceof Map ? nameMap : new Map(Object.entries(nameMap || {}));
|
|
236
|
+
try {
|
|
237
|
+
let rows = [];
|
|
238
|
+
try { rows = src.prepare('SELECT snsId,userName,createTime,type,content,attrBuf FROM SnsInfo ORDER BY createTime DESC LIMIT 5000').all(); }
|
|
239
|
+
catch { return { events: [], persons: [], topics: [] }; } // no SnsInfo table
|
|
240
|
+
for (const r of rows) {
|
|
241
|
+
const wxid = String(r.userName || '');
|
|
242
|
+
if (!wxid) continue;
|
|
243
|
+
const text = r.content ? snsPostText(r.content) : '';
|
|
244
|
+
const media = r.content ? snsMediaUrls(r.content) : [];
|
|
245
|
+
if (!text && !media.length) continue; // skip empty / pure-ad shells
|
|
246
|
+
const occurredAt = (Number(r.createTime) || 0) * 1000; // SnsInfo.createTime is seconds
|
|
247
|
+
if (!occurredAt) continue;
|
|
248
|
+
const isSelf = !!(selfWxid && wxid === selfWxid);
|
|
249
|
+
const nick = names.get(wxid) || (r.attrBuf ? snsNickname(r.attrBuf, wxid) : '') || wxid;
|
|
250
|
+
const actor = isSelf ? SELF_ID : `person-wechat-${wxid}`;
|
|
251
|
+
if (!isSelf && !persons.has(actor)) {
|
|
252
|
+
const nm = nick && nick !== wxid ? [nick, wxid] : [wxid];
|
|
253
|
+
persons.set(actor, { type: 'person', subtype: 'contact', id: actor, names: nm, identifiers: { wechatId: wxid }, source: SRC(actor), ingestedAt: Date.now() });
|
|
254
|
+
}
|
|
255
|
+
const title = (text || `[图片] ${nick}的朋友圈`).replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
256
|
+
events.push({
|
|
257
|
+
type: 'event', subtype: 'post', id: `wechat-sns:${r.snsId}`,
|
|
258
|
+
occurredAt, actor, participants: [actor],
|
|
259
|
+
content: { title: title || '(朋友圈)', text: text || undefined },
|
|
260
|
+
source: SRC(`sns-${r.snsId}`, occurredAt),
|
|
261
|
+
extra: { kind: 'moment', isSelf, poster: nick, mediaCount: media.length, media: media.slice(0, 9) },
|
|
262
|
+
ingestedAt: Date.now(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (selfWxid) persons.set(SELF_ID, { type: 'person', subtype: 'contact', id: SELF_ID, names: ['我(微信)'], source: SRC(SELF_ID), ingestedAt: Date.now() });
|
|
266
|
+
} finally {
|
|
267
|
+
src.close();
|
|
268
|
+
}
|
|
269
|
+
return { events, persons: [...persons.values()], topics: [] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { computeKeyCandidates, deriveAndDecrypt, parseEvents, parseSnsEvents, snsPostText, snsMediaUrls, snsNickname };
|
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.
|
|
3
|
+
"version": "0.4.37",
|
|
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",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"./forensics/salvage-ingest": "./lib/forensics/salvage-ingest.js",
|
|
77
77
|
"./forensics/qq-nt-collect": "./lib/forensics/qq-nt-collect.js",
|
|
78
78
|
"./forensics/wechat-collect": "./lib/forensics/wechat-collect.js",
|
|
79
|
+
"./forensics/qzone-collect": "./lib/forensics/qzone-collect.js",
|
|
79
80
|
"./forensics/plaintext-db-collect": "./lib/forensics/plaintext-db-collect.js"
|
|
80
81
|
},
|
|
81
82
|
"scripts": {
|