@chainlesschain/personal-data-hub 0.2.1 → 0.2.3
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/social-toutiao-kuaishou-scaffold.test.js +58 -16
- package/__tests__/adapters/wechat-frida-agent.test.js +132 -1
- package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
- package/__tests__/longtail-adapters.test.js +60 -14
- package/__tests__/messaging-qq-snapshot.test.js +294 -0
- package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
- package/__tests__/shopping-snapshot.test.js +438 -0
- package/__tests__/social-adapters.test.js +91 -17
- package/__tests__/social-bilibili-snapshot.test.js +278 -0
- package/__tests__/social-douyin-snapshot.test.js +253 -0
- package/__tests__/social-kuaishou-snapshot.test.js +309 -0
- package/__tests__/social-toutiao-snapshot.test.js +314 -0
- package/__tests__/social-weibo-snapshot.test.js +234 -0
- package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
- package/__tests__/travel-maps-snapshot.test.js +426 -0
- package/__tests__/vault-driver-error.test.js +74 -0
- package/__tests__/wechat-adapter.test.js +118 -0
- package/lib/adapters/messaging-qq/index.js +498 -92
- package/lib/adapters/shopping-jd/index.js +228 -25
- package/lib/adapters/shopping-meituan/index.js +222 -26
- package/lib/adapters/shopping-pinduoduo/index.js +275 -0
- package/lib/adapters/social-bilibili/adapter.js +500 -0
- package/lib/adapters/social-bilibili/index.js +21 -169
- package/lib/adapters/social-douyin/index.js +454 -63
- package/lib/adapters/social-kuaishou/index.js +379 -127
- package/lib/adapters/social-toutiao/index.js +400 -130
- package/lib/adapters/social-weibo/index.js +393 -95
- package/lib/adapters/social-xiaohongshu/index.js +389 -49
- package/lib/adapters/travel-baidu-map/index.js +286 -26
- package/lib/adapters/travel-tencent-map/index.js +414 -0
- package/lib/adapters/wechat/content-parser.js +11 -2
- package/lib/adapters/wechat/db-reader.js +88 -10
- package/lib/adapters/wechat/frida-agent/loader.js +7 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +140 -18
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +8 -0
- package/lib/adapters/wechat/normalize.js +12 -3
- package/lib/index.js +5 -1
- package/lib/vault.js +60 -8
- package/package.json +2 -1
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BilibiliAdapter — A8 v0.1 (2026-05-22)
|
|
5
|
+
*
|
|
6
|
+
* Two sync modes, mutually exclusive based on opts:
|
|
7
|
+
*
|
|
8
|
+
* 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
|
|
9
|
+
* JSON produced by the phone's own WebView+OkHttp pipeline. This is
|
|
10
|
+
* the desktop-independent path for Plan A v0.1; Android does cookie
|
|
11
|
+
* capture + HTTP fetch + parsing in Kotlin, then writes the snapshot
|
|
12
|
+
* to filesDir and asks LocalCcRunner to ingest it. Adapter is stateless.
|
|
13
|
+
*
|
|
14
|
+
* 2. sqlite mode (opts.dbPath, legacy): Phase 7.5 AndroidExtractor pulled
|
|
15
|
+
* the app DB via `adb backup`; this mode parses `history` + `bili_favourite`
|
|
16
|
+
* tables. Retained for backward compat — desktop users with rooted devices
|
|
17
|
+
* can still go this route.
|
|
18
|
+
*
|
|
19
|
+
* Snapshot schema (mirrors Android-side BilibiliLocalCollector.SCHEMA_VERSION):
|
|
20
|
+
*
|
|
21
|
+
* {
|
|
22
|
+
* "schemaVersion": 1,
|
|
23
|
+
* "snapshottedAt": <epoch-ms>,
|
|
24
|
+
* "account": { "uid": "12345", "displayName": "alice" },
|
|
25
|
+
* "events": [
|
|
26
|
+
* { "kind": "history", "id": "BV1xx", "capturedAt": <ms>,
|
|
27
|
+
* "title": "...", "bvid": "...", "avid": ..., "duration": ...,
|
|
28
|
+
* "uploader": "...", "uploaderMid": ..., "part": "..." },
|
|
29
|
+
* { "kind": "favourite", "id": "fav-<bvid>", "capturedAt": <ms>,
|
|
30
|
+
* "title": "...", "bvid": "...", "folderName": "...", "uploader": "..." },
|
|
31
|
+
* { "kind": "dynamic", "id": "dyn-<rid>", "capturedAt": <ms>,
|
|
32
|
+
* "summary": "...", "dynamicType": "video|text|image|...",
|
|
33
|
+
* "authorMid": ..., "authorName": "..." },
|
|
34
|
+
* { "kind": "follow", "id": "follow-<mid>", "capturedAt": <ms>,
|
|
35
|
+
* "mid": "...", "uname": "...", "face": "...", "sign": "..." }
|
|
36
|
+
* ]
|
|
37
|
+
* }
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require("node:fs");
|
|
41
|
+
const { newId } = require("../../ids");
|
|
42
|
+
const {
|
|
43
|
+
ENTITY_TYPES,
|
|
44
|
+
PERSON_SUBTYPES,
|
|
45
|
+
EVENT_SUBTYPES,
|
|
46
|
+
ITEM_SUBTYPES,
|
|
47
|
+
CAPTURED_BY,
|
|
48
|
+
} = require("../../constants");
|
|
49
|
+
|
|
50
|
+
const NAME = "social-bilibili";
|
|
51
|
+
const VERSION = "0.6.0";
|
|
52
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
53
|
+
|
|
54
|
+
const KIND_HISTORY = "history";
|
|
55
|
+
const KIND_FAVOURITE = "favourite";
|
|
56
|
+
const KIND_DYNAMIC = "dynamic";
|
|
57
|
+
const KIND_FOLLOW = "follow";
|
|
58
|
+
const VALID_KINDS = Object.freeze([
|
|
59
|
+
KIND_HISTORY,
|
|
60
|
+
KIND_FAVOURITE,
|
|
61
|
+
KIND_DYNAMIC,
|
|
62
|
+
KIND_FOLLOW,
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
function stableOriginalId(kind, id) {
|
|
66
|
+
// Coerce numeric IDs to string — Bilibili APIs return mid/avid/rid as
|
|
67
|
+
// integers, but originalId is a string in raw_events schema. Without this
|
|
68
|
+
// coercion, `typeof 999 === "string"` is false → falls to unknown- prefix
|
|
69
|
+
// and breaks idempotency across syncs (every sync emits a new "unknown-"
|
|
70
|
+
// ID, raw_events table grows unbounded).
|
|
71
|
+
const stringified =
|
|
72
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
73
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
74
|
+
null;
|
|
75
|
+
const safe =
|
|
76
|
+
stringified ||
|
|
77
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
78
|
+
return `bilibili:${kind}:${safe}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseTime(v) {
|
|
82
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
83
|
+
if (typeof v === "string") {
|
|
84
|
+
if (/^\d+$/.test(v)) {
|
|
85
|
+
const n = parseInt(v, 10);
|
|
86
|
+
return n > 1e12 ? n : n * 1000;
|
|
87
|
+
}
|
|
88
|
+
const t = Date.parse(v);
|
|
89
|
+
return Number.isFinite(t) ? t : null;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function trySelect(db, sql) {
|
|
95
|
+
try {
|
|
96
|
+
return db.prepare(sql).all();
|
|
97
|
+
} catch (_e) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class BilibiliAdapter {
|
|
103
|
+
constructor(opts = {}) {
|
|
104
|
+
// Stateless in snapshot mode — account.uid optional. Sqlite-mode still
|
|
105
|
+
// requires it (the legacy path before A8); see _syncViaSqlite below.
|
|
106
|
+
this.account = opts.account || null;
|
|
107
|
+
this._dbPath = opts.dbPath || null;
|
|
108
|
+
|
|
109
|
+
this.name = NAME;
|
|
110
|
+
this.version = VERSION;
|
|
111
|
+
this.capabilities = [
|
|
112
|
+
"sync:snapshot",
|
|
113
|
+
"sync:sqlite",
|
|
114
|
+
"parse:bilibili-history",
|
|
115
|
+
"parse:bilibili-favourite",
|
|
116
|
+
"parse:bilibili-dynamic",
|
|
117
|
+
"parse:bilibili-follow",
|
|
118
|
+
];
|
|
119
|
+
this.extractMode = "device-pull";
|
|
120
|
+
this.rateLimits = {};
|
|
121
|
+
this.dataDisclosure = {
|
|
122
|
+
fields: [
|
|
123
|
+
"bilibili:history (avid / bvid / title / view_at / duration / uploader)",
|
|
124
|
+
"bilibili:favourite (folder / video / save_time / uploader)",
|
|
125
|
+
"bilibili:dynamic (rid / type / summary / author)",
|
|
126
|
+
"bilibili:follow (mid / uname / face)",
|
|
127
|
+
],
|
|
128
|
+
sensitivity: "medium",
|
|
129
|
+
legalGate: false,
|
|
130
|
+
defaultInclude: {
|
|
131
|
+
history: true,
|
|
132
|
+
favourite: true,
|
|
133
|
+
dynamic: true,
|
|
134
|
+
follow: true,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// _deps injection seam (see .claude/rules/cli-dev.md — vi.mock("fs") does
|
|
139
|
+
// not intercept require under inlined CJS; tests override via _deps).
|
|
140
|
+
this._deps = {
|
|
141
|
+
fs,
|
|
142
|
+
dbDriverFactory: opts.dbDriverFactory || null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async authenticate(ctx = {}) {
|
|
147
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
148
|
+
try {
|
|
149
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: "INPUT_PATH_UNREADABLE",
|
|
154
|
+
message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, mode: "snapshot-file" };
|
|
158
|
+
}
|
|
159
|
+
if (this._dbPath || (ctx && typeof ctx.dbPath === "string")) {
|
|
160
|
+
return { ok: true, mode: "sqlite" };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason: "NO_INPUT",
|
|
165
|
+
message:
|
|
166
|
+
"social-bilibili.authenticate: needs opts.inputPath (snapshot mode) OR opts.dbPath (sqlite mode)",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async healthCheck() {
|
|
171
|
+
return { ok: true, lastChecked: Date.now() };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async *sync(opts = {}) {
|
|
175
|
+
// Snapshot mode takes priority — the in-APK Android cc path always passes
|
|
176
|
+
// inputPath. Sqlite mode is the legacy Phase 7.5 desktop path; only kicks
|
|
177
|
+
// in when caller explicitly provides dbPath (no auto-engage to avoid
|
|
178
|
+
// surprising desktop users who upgrade from sqlite-only adapter).
|
|
179
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
180
|
+
yield* this._syncViaSnapshot(opts);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const dbPath = opts.dbPath || this._dbPath;
|
|
184
|
+
if (dbPath) {
|
|
185
|
+
yield* this._syncViaSqlite({ ...opts, dbPath });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
throw new Error(
|
|
189
|
+
"social-bilibili.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.dbPath (sqlite mode, Phase 7.5 desktop extractor)"
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async *_syncViaSnapshot(opts) {
|
|
194
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
195
|
+
const snapshot = JSON.parse(raw);
|
|
196
|
+
if (
|
|
197
|
+
!snapshot ||
|
|
198
|
+
typeof snapshot !== "object" ||
|
|
199
|
+
snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
|
|
200
|
+
) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`social-bilibili.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const fallbackCapturedAt =
|
|
206
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
|
|
207
|
+
? Math.floor(snapshot.snapshottedAt)
|
|
208
|
+
: Date.now();
|
|
209
|
+
|
|
210
|
+
const account = snapshot.account && typeof snapshot.account === "object"
|
|
211
|
+
? snapshot.account
|
|
212
|
+
: null;
|
|
213
|
+
const include = opts.include || {};
|
|
214
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
215
|
+
|
|
216
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
217
|
+
let emitted = 0;
|
|
218
|
+
for (const ev of events) {
|
|
219
|
+
if (emitted >= limit) return;
|
|
220
|
+
if (!ev || typeof ev !== "object") continue;
|
|
221
|
+
const kind = ev.kind;
|
|
222
|
+
if (!VALID_KINDS.includes(kind)) continue;
|
|
223
|
+
// Per-kind include gate. Default: include everything.
|
|
224
|
+
if (include[kind] === false) continue;
|
|
225
|
+
|
|
226
|
+
const capturedAt =
|
|
227
|
+
parseTime(ev.capturedAt) ||
|
|
228
|
+
parseTime(ev.time) ||
|
|
229
|
+
fallbackCapturedAt;
|
|
230
|
+
const id =
|
|
231
|
+
(typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
|
|
232
|
+
ev.bvid ||
|
|
233
|
+
ev.mid ||
|
|
234
|
+
ev.rid ||
|
|
235
|
+
null;
|
|
236
|
+
|
|
237
|
+
yield {
|
|
238
|
+
adapter: NAME,
|
|
239
|
+
kind,
|
|
240
|
+
originalId: stableOriginalId(kind, id),
|
|
241
|
+
capturedAt,
|
|
242
|
+
payload: { ...ev, account },
|
|
243
|
+
};
|
|
244
|
+
emitted += 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async *_syncViaSqlite(opts) {
|
|
249
|
+
// Legacy Phase 7.5 path — requires account.uid in constructor and a DB
|
|
250
|
+
// pulled via the desktop AndroidExtractor. Preserved verbatim from the
|
|
251
|
+
// pre-A8 adapter so existing desktop users don't regress.
|
|
252
|
+
if (!this.account || !this.account.uid) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"social-bilibili._syncViaSqlite: account.uid required (set via new BilibiliAdapter({ account: { uid } }) in cli wiring)"
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const dbPath = opts.dbPath;
|
|
258
|
+
if (!dbPath || !this._deps.fs.existsSync(dbPath)) return;
|
|
259
|
+
const Driver = this._deps.dbDriverFactory
|
|
260
|
+
? this._deps.dbDriverFactory()
|
|
261
|
+
: require("better-sqlite3-multiple-ciphers");
|
|
262
|
+
const db = new Driver(dbPath, { readonly: true });
|
|
263
|
+
try {
|
|
264
|
+
const history = trySelect(db, "SELECT * FROM history ORDER BY view_at DESC LIMIT 5000") || [];
|
|
265
|
+
for (const row of history) {
|
|
266
|
+
yield {
|
|
267
|
+
adapter: NAME,
|
|
268
|
+
kind: KIND_HISTORY,
|
|
269
|
+
originalId: stableOriginalId(
|
|
270
|
+
KIND_HISTORY,
|
|
271
|
+
row.id || row._id || row.kid || row.bvid || row.avid
|
|
272
|
+
),
|
|
273
|
+
capturedAt: parseTime(row.view_at || row.create_at || row.time),
|
|
274
|
+
payload: {
|
|
275
|
+
kind: KIND_HISTORY,
|
|
276
|
+
title: row.title || row.video_title,
|
|
277
|
+
bvid: row.bvid,
|
|
278
|
+
avid: row.avid,
|
|
279
|
+
duration: row.duration || row.progress,
|
|
280
|
+
uploader: row.uploader || row.up_name,
|
|
281
|
+
part: row.part_name,
|
|
282
|
+
_row: row,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const favs = trySelect(db, "SELECT * FROM bili_favourite ORDER BY save_time DESC LIMIT 5000") || [];
|
|
287
|
+
for (const row of favs) {
|
|
288
|
+
yield {
|
|
289
|
+
adapter: NAME,
|
|
290
|
+
kind: KIND_FAVOURITE,
|
|
291
|
+
originalId: stableOriginalId(
|
|
292
|
+
KIND_FAVOURITE,
|
|
293
|
+
row.id || row.fav_id || row.bvid
|
|
294
|
+
),
|
|
295
|
+
capturedAt: parseTime(row.save_time || row.time),
|
|
296
|
+
payload: {
|
|
297
|
+
kind: KIND_FAVOURITE,
|
|
298
|
+
title: row.title || row.video_title,
|
|
299
|
+
bvid: row.bvid,
|
|
300
|
+
avid: row.avid,
|
|
301
|
+
folderName: row.folder_name,
|
|
302
|
+
uploader: row.uploader || row.up_name,
|
|
303
|
+
_row: row,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
try { db.close(); } catch (_e) { /* ignore */ }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
normalize(raw) {
|
|
313
|
+
if (!raw || !raw.payload) {
|
|
314
|
+
throw new Error("BilibiliAdapter.normalize: payload missing");
|
|
315
|
+
}
|
|
316
|
+
const ingestedAt = Date.now();
|
|
317
|
+
const kind = raw.kind || raw.payload.kind;
|
|
318
|
+
const p = raw.payload;
|
|
319
|
+
const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
|
|
320
|
+
const source = {
|
|
321
|
+
adapter: NAME,
|
|
322
|
+
adapterVersion: VERSION,
|
|
323
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
324
|
+
capturedBy: CAPTURED_BY.API,
|
|
325
|
+
originalId: raw.originalId,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
if (kind === KIND_HISTORY) {
|
|
329
|
+
return normalizeHistory(p, source, occurredAt, ingestedAt);
|
|
330
|
+
}
|
|
331
|
+
if (kind === KIND_FAVOURITE) {
|
|
332
|
+
return normalizeFavourite(p, source, occurredAt, ingestedAt);
|
|
333
|
+
}
|
|
334
|
+
if (kind === KIND_DYNAMIC) {
|
|
335
|
+
return normalizeDynamic(p, source, occurredAt, ingestedAt);
|
|
336
|
+
}
|
|
337
|
+
if (kind === KIND_FOLLOW) {
|
|
338
|
+
return normalizeFollow(p, source, occurredAt, ingestedAt);
|
|
339
|
+
}
|
|
340
|
+
throw new Error(`BilibiliAdapter.normalize: unknown kind ${kind}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function normalizeHistory(p, source, occurredAt, ingestedAt) {
|
|
345
|
+
const title = p.title || "(no title)";
|
|
346
|
+
const bvid = p.bvid || null;
|
|
347
|
+
const itemId = bvid ? `item-bilibili-video-${bvid}` : `item-bilibili-video-${newId()}`;
|
|
348
|
+
const item = {
|
|
349
|
+
id: itemId,
|
|
350
|
+
type: ENTITY_TYPES.ITEM,
|
|
351
|
+
subtype: ITEM_SUBTYPES.MEDIA,
|
|
352
|
+
name: title,
|
|
353
|
+
ingestedAt,
|
|
354
|
+
source,
|
|
355
|
+
extra: {
|
|
356
|
+
kind: "bilibili-video",
|
|
357
|
+
bvid,
|
|
358
|
+
avid: p.avid || null,
|
|
359
|
+
uploader: p.uploader || null,
|
|
360
|
+
uploaderMid: p.uploaderMid || null,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
events: [{
|
|
365
|
+
id: newId(),
|
|
366
|
+
type: ENTITY_TYPES.EVENT,
|
|
367
|
+
subtype: EVENT_SUBTYPES.BROWSE,
|
|
368
|
+
occurredAt,
|
|
369
|
+
actor: "person-self",
|
|
370
|
+
content: { title },
|
|
371
|
+
ingestedAt,
|
|
372
|
+
source,
|
|
373
|
+
extra: {
|
|
374
|
+
platform: "bilibili",
|
|
375
|
+
bvid,
|
|
376
|
+
avid: p.avid || null,
|
|
377
|
+
duration: p.duration || null,
|
|
378
|
+
uploader: p.uploader || null,
|
|
379
|
+
part: p.part || null,
|
|
380
|
+
itemRef: itemId,
|
|
381
|
+
},
|
|
382
|
+
}],
|
|
383
|
+
persons: [],
|
|
384
|
+
places: [],
|
|
385
|
+
items: [item],
|
|
386
|
+
topics: [],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function normalizeFavourite(p, source, occurredAt, ingestedAt) {
|
|
391
|
+
const title = p.title || "(no title)";
|
|
392
|
+
const bvid = p.bvid || null;
|
|
393
|
+
const itemId = bvid ? `item-bilibili-video-${bvid}` : `item-bilibili-video-${newId()}`;
|
|
394
|
+
const item = {
|
|
395
|
+
id: itemId,
|
|
396
|
+
type: ENTITY_TYPES.ITEM,
|
|
397
|
+
subtype: ITEM_SUBTYPES.MEDIA,
|
|
398
|
+
name: title,
|
|
399
|
+
ingestedAt,
|
|
400
|
+
source,
|
|
401
|
+
extra: {
|
|
402
|
+
kind: "bilibili-video",
|
|
403
|
+
bvid,
|
|
404
|
+
avid: p.avid || null,
|
|
405
|
+
uploader: p.uploader || null,
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
return {
|
|
409
|
+
events: [{
|
|
410
|
+
id: newId(),
|
|
411
|
+
type: ENTITY_TYPES.EVENT,
|
|
412
|
+
subtype: EVENT_SUBTYPES.LIKE,
|
|
413
|
+
occurredAt,
|
|
414
|
+
actor: "person-self",
|
|
415
|
+
content: { title },
|
|
416
|
+
ingestedAt,
|
|
417
|
+
source,
|
|
418
|
+
extra: {
|
|
419
|
+
platform: "bilibili",
|
|
420
|
+
bvid,
|
|
421
|
+
avid: p.avid || null,
|
|
422
|
+
folderName: p.folderName || null,
|
|
423
|
+
uploader: p.uploader || null,
|
|
424
|
+
itemRef: itemId,
|
|
425
|
+
},
|
|
426
|
+
}],
|
|
427
|
+
persons: [],
|
|
428
|
+
places: [],
|
|
429
|
+
items: [item],
|
|
430
|
+
topics: [],
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function normalizeDynamic(p, source, occurredAt, ingestedAt) {
|
|
435
|
+
const summary = p.summary || p.content || "(no summary)";
|
|
436
|
+
return {
|
|
437
|
+
events: [{
|
|
438
|
+
id: newId(),
|
|
439
|
+
type: ENTITY_TYPES.EVENT,
|
|
440
|
+
subtype: EVENT_SUBTYPES.BROWSE,
|
|
441
|
+
occurredAt,
|
|
442
|
+
actor: "person-self",
|
|
443
|
+
content: { title: summary.slice(0, 200) },
|
|
444
|
+
ingestedAt,
|
|
445
|
+
source,
|
|
446
|
+
extra: {
|
|
447
|
+
platform: "bilibili",
|
|
448
|
+
dynamicType: p.dynamicType || "unknown",
|
|
449
|
+
rid: p.rid || null,
|
|
450
|
+
authorMid: p.authorMid || null,
|
|
451
|
+
authorName: p.authorName || null,
|
|
452
|
+
summary,
|
|
453
|
+
},
|
|
454
|
+
}],
|
|
455
|
+
persons: [],
|
|
456
|
+
places: [],
|
|
457
|
+
items: [],
|
|
458
|
+
topics: [],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function normalizeFollow(p, source, occurredAt, ingestedAt) {
|
|
463
|
+
const mid =
|
|
464
|
+
(typeof p.mid === "string" && p.mid) ||
|
|
465
|
+
(typeof p.mid === "number" && String(p.mid)) ||
|
|
466
|
+
`unknown-${newId()}`;
|
|
467
|
+
const uname = p.uname || "(unnamed)";
|
|
468
|
+
const person = {
|
|
469
|
+
id: `person-bilibili-${mid}`,
|
|
470
|
+
type: ENTITY_TYPES.PERSON,
|
|
471
|
+
subtype: PERSON_SUBTYPES.CONTACT,
|
|
472
|
+
names: [uname],
|
|
473
|
+
ingestedAt,
|
|
474
|
+
source,
|
|
475
|
+
identifiers: {
|
|
476
|
+
"bilibili-mid": [mid],
|
|
477
|
+
},
|
|
478
|
+
extra: {
|
|
479
|
+
platform: "bilibili",
|
|
480
|
+
face: p.face || null,
|
|
481
|
+
sign: p.sign || null,
|
|
482
|
+
followedAt: occurredAt,
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
return {
|
|
486
|
+
events: [],
|
|
487
|
+
persons: [person],
|
|
488
|
+
places: [],
|
|
489
|
+
items: [],
|
|
490
|
+
topics: [],
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
module.exports = {
|
|
495
|
+
BilibiliAdapter,
|
|
496
|
+
NAME,
|
|
497
|
+
VERSION,
|
|
498
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
499
|
+
VALID_KINDS,
|
|
500
|
+
};
|
|
@@ -1,171 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 13.1 — Bilibili (B站) adapter.
|
|
3
|
-
*
|
|
4
|
-
* Source: B站 Android app stores user data in SQLite (per sjqz/parsers/
|
|
5
|
-
* social.py BilibiliParser). Phase 7.5 AndroidExtractor pulls the DB
|
|
6
|
-
* to a local cache; this adapter parses it.
|
|
7
|
-
*
|
|
8
|
-
* Tables (sjqz reference):
|
|
9
|
-
* - history watched videos
|
|
10
|
-
* - bili_favourite favorited videos / playlists
|
|
11
|
-
* - bili_user user profile
|
|
12
|
-
* - bili_message 私信
|
|
13
|
-
*
|
|
14
|
-
* Each row → Event with subtype "browse" (history) / "like" (favorites)
|
|
15
|
-
* / "message" (DMs) per UnifiedSchema enum.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
1
|
"use strict";
|
|
19
2
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
fields: [
|
|
42
|
-
"bilibili:history (avid / bvid / title / view_at / duration)",
|
|
43
|
-
"bilibili:favourite (folder / video / save_time)",
|
|
44
|
-
"bilibili:message (peer / content / time)",
|
|
45
|
-
],
|
|
46
|
-
sensitivity: "medium",
|
|
47
|
-
legalGate: false,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async authenticate() {
|
|
52
|
-
return { ok: true, account: this.account.uid };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async healthCheck() {
|
|
56
|
-
return { ok: true, lastChecked: Date.now() };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async *sync(opts = {}) {
|
|
60
|
-
const dbPath = opts.dbPath || this._dbPath;
|
|
61
|
-
if (!dbPath || !fs.existsSync(dbPath)) return;
|
|
62
|
-
const Driver = this._dbDriverFactory
|
|
63
|
-
? this._dbDriverFactory()
|
|
64
|
-
: require("better-sqlite3-multiple-ciphers");
|
|
65
|
-
const db = new Driver(dbPath, { readonly: true });
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const history = trySelect(db, "SELECT * FROM history ORDER BY view_at DESC LIMIT 5000") || [];
|
|
69
|
-
for (const row of history) {
|
|
70
|
-
yield {
|
|
71
|
-
adapter: NAME,
|
|
72
|
-
originalId: `history-${row.id || row._id || row.kid || row.bvid || row.avid}`,
|
|
73
|
-
capturedAt: parseTime(row.view_at || row.create_at || row.time),
|
|
74
|
-
payload: { row, kind: "history" },
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const favs = trySelect(db, "SELECT * FROM bili_favourite ORDER BY save_time DESC LIMIT 5000") || [];
|
|
79
|
-
for (const row of favs) {
|
|
80
|
-
yield {
|
|
81
|
-
adapter: NAME,
|
|
82
|
-
originalId: `fav-${row.id || row.fav_id || row.bvid}`,
|
|
83
|
-
capturedAt: parseTime(row.save_time || row.time),
|
|
84
|
-
payload: { row, kind: "favourite" },
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
} finally {
|
|
88
|
-
try { db.close(); } catch (_e) {}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
normalize(raw) {
|
|
93
|
-
if (!raw || !raw.payload || !raw.payload.row) {
|
|
94
|
-
throw new Error("BilibiliAdapter.normalize: row missing");
|
|
95
|
-
}
|
|
96
|
-
const { kind, row } = raw.payload;
|
|
97
|
-
const now = Date.now();
|
|
98
|
-
const occurredAt = parseTime(row.view_at || row.save_time || row.create_at || row.time) || now;
|
|
99
|
-
const source = {
|
|
100
|
-
adapter: NAME, adapterVersion: VERSION,
|
|
101
|
-
originalId: raw.originalId, capturedAt: occurredAt,
|
|
102
|
-
capturedBy: "sqlite",
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
if (kind === "favourite") {
|
|
106
|
-
return {
|
|
107
|
-
events: [{
|
|
108
|
-
id: newId(),
|
|
109
|
-
type: "event",
|
|
110
|
-
subtype: "like",
|
|
111
|
-
occurredAt,
|
|
112
|
-
actor: "person-self",
|
|
113
|
-
content: {
|
|
114
|
-
title: row.title || row.video_title || "(no title)",
|
|
115
|
-
},
|
|
116
|
-
ingestedAt: now,
|
|
117
|
-
source,
|
|
118
|
-
extra: {
|
|
119
|
-
bvid: row.bvid || null,
|
|
120
|
-
avid: row.avid || null,
|
|
121
|
-
folder: row.folder_name || null,
|
|
122
|
-
uploader: row.uploader || row.up_name || null,
|
|
123
|
-
},
|
|
124
|
-
}],
|
|
125
|
-
persons: [], places: [], items: [], topics: [],
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
// history → browse event
|
|
129
|
-
return {
|
|
130
|
-
events: [{
|
|
131
|
-
id: newId(),
|
|
132
|
-
type: "event",
|
|
133
|
-
subtype: "browse",
|
|
134
|
-
occurredAt,
|
|
135
|
-
actor: "person-self",
|
|
136
|
-
content: {
|
|
137
|
-
title: row.title || row.video_title || "(no title)",
|
|
138
|
-
},
|
|
139
|
-
ingestedAt: now,
|
|
140
|
-
source,
|
|
141
|
-
extra: {
|
|
142
|
-
bvid: row.bvid || null,
|
|
143
|
-
avid: row.avid || null,
|
|
144
|
-
duration: row.duration || row.progress || null,
|
|
145
|
-
uploader: row.uploader || row.up_name || null,
|
|
146
|
-
part: row.part_name || null,
|
|
147
|
-
},
|
|
148
|
-
}],
|
|
149
|
-
persons: [], places: [], items: [], topics: [],
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function trySelect(db, sql) {
|
|
155
|
-
try { return db.prepare(sql).all(); } catch (_e) { return null; }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function parseTime(v) {
|
|
159
|
-
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
160
|
-
if (typeof v === "string") {
|
|
161
|
-
if (/^\d+$/.test(v)) {
|
|
162
|
-
const n = parseInt(v, 10);
|
|
163
|
-
return n > 1e12 ? n : n * 1000;
|
|
164
|
-
}
|
|
165
|
-
const t = Date.parse(v);
|
|
166
|
-
return Number.isFinite(t) ? t : null;
|
|
167
|
-
}
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
module.exports = { BilibiliAdapter, NAME, VERSION };
|
|
3
|
+
// Phase 13.1 → A8 v0.1 (2026-05-22): refactored into adapter.js with snapshot
|
|
4
|
+
// mode added for the Android in-APK cc path. Legacy sqlite mode preserved.
|
|
5
|
+
// This file is the public entry point — re-exports from adapter.js so callers
|
|
6
|
+
// using `require("@chainlesschain/personal-data-hub/lib/adapters/social-bilibili")`
|
|
7
|
+
// continue to see the same { BilibiliAdapter, NAME, VERSION } shape.
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
BilibiliAdapter,
|
|
11
|
+
NAME,
|
|
12
|
+
VERSION,
|
|
13
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
14
|
+
VALID_KINDS,
|
|
15
|
+
} = require("./adapter");
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
BilibiliAdapter,
|
|
19
|
+
NAME,
|
|
20
|
+
VERSION,
|
|
21
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
22
|
+
VALID_KINDS,
|
|
23
|
+
};
|